[
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"root\": true,\n  \"extends\": [\"next/core-web-vitals\", \"next/typescript\"],\n  \"rules\": {\n    \"react-hooks/exhaustive-deps\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\"\n  },\n  \"ignorePatterns\": [\n    \"docs/**\"\n  ]\n}\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐞 提交 Bug\ntitle: '[bug] '\ndescription: 详细的描述一个 Bug\nlabels: ['type: bug']\n\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## 首先请阅读\n        1. 请先搜索 [note-gen/issues](https://github.com/codexu/note-gen/issues) 中是否已存在此问题。\n        2. 尝试下载最新版本的 NoteGen 并测试是否还存在此问题。\n        3. 请确保这是 App 的问题，而不是 AI 或代理等问题。\n        4. 请按照提交要求详细的描述 Bug，提供全面的信息。\n        5. 请在社区内友善发言。\n\n  - type: textarea\n    id: description\n    attributes:\n      label: 详细描述这个 Bug\n      description: 请详细的描述这个 Bug，包括重现步骤、预期行为和实际行为，如果可以建议附带截图或视频。\n      placeholder: Bug description\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: NoteGen 版本\n      placeholder: 请填写你当前使用的 NoteGen 版本。\n    validations:\n      required: true\n\n  - type: dropdown\n    id: os\n    attributes:\n      label: 操作系统\n      multiple: true\n      options:\n        - Windows\n        - macOS\n        - Linux\n        - Android\n        - iOS\n    validations:\n      required: true\n\n  - type: textarea\n    id: log\n    attributes:\n      label: 报错日志\n      description: 可以通过右键呼出开发者工具，将报错信息粘贴在此处。\n      placeholder: Bug logs\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "contact_links:\n  - name: 💬 讨论问题\n    url: https://github.com/codexu/note-gen/discussions\n    about: 提出问题并与其他 NoteGen 用户或维护者交谈"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 💡 改进建议\ntitle: '[feat] '\ndescription: 你有什么好的灵感？\nlabels: ['type: feat']\n\nbody:\n  - type: textarea\n    id: problem\n    attributes:\n      label: 描述你的建议\n      description: 清楚的描述这个建议可以解决什么问题\n    validations:\n      required: true"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: 'publish'\n\non:\n  push:\n    branches:\n      - release\n\njobs:\n  build-android:\n    outputs:\n      appVersion: ${{ steps.get_version.outputs.version }}\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    \n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup pnpm\n        uses: pnpm/action-setup@v4\n        with:\n          version: 9\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n          cache: 'pnpm'\n\n      - name: Install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android\n\n      - name: Setup Android SDK\n        uses: android-actions/setup-android@v3\n\n      - name: Install Android NDK\n        run: |\n          echo \"y\" | sdkmanager \"ndk;29.0.14206865\"\n          echo \"ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865\" >> $GITHUB_ENV\n          echo \"NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865\" >> $GITHUB_ENV\n\n      - name: Cache Rust dependencies\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/registry/index\n            ~/.cargo/registry/cache\n            ~/.cargo/git/db\n            src-tauri/target\n          key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-cargo-android-\n\n      - name: Install frontend dependencies\n        run: pnpm install\n\n      - name: Build frontend\n        run: pnpm build\n\n      - name: Setup NDK toolchain\n        run: |\n          export PATH=$PATH:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin\n          ln -sf llvm-ranlib $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android-ranlib || true\n\n      - name: Initialize and Build Android\n        run: |\n          export PATH=$PATH:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin\n          \n          # Initialize Android project if not exists\n          if [ ! -d \"src-tauri/gen/android\" ]; then\n            echo \"📱 Initializing Android project...\"\n            pnpm tauri android init\n          else\n            echo \"✅ Android project already initialized\"\n          fi\n          \n          # Verify initialization\n          if [ ! -d \"src-tauri/gen/android\" ]; then\n            echo \"❌ Android initialization failed\"\n            exit 1\n          fi\n          \n          # Set custom Android icon\n          echo \"🎨 Setting custom Android icon...\"\n          ICON_SOURCE=\"public/app-ios-icon.png\"\n          MIPMAP_DIRS=(\n            \"src-tauri/gen/android/app/src/main/res/mipmap-mdpi\"\n            \"src-tauri/gen/android/app/src/main/res/mipmap-hdpi\"\n            \"src-tauri/gen/android/app/src/main/res/mipmap-xhdpi\"\n            \"src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi\"\n            \"src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi\"\n          )\n          \n          # Install ImageMagick for icon conversion\n          sudo apt-get update && sudo apt-get install -y imagemagick\n          \n          # Generate different sizes\n          convert \"$ICON_SOURCE\" -resize 48x48 \"${MIPMAP_DIRS[0]}/ic_launcher.png\"\n          convert \"$ICON_SOURCE\" -resize 72x72 \"${MIPMAP_DIRS[1]}/ic_launcher.png\"\n          convert \"$ICON_SOURCE\" -resize 96x96 \"${MIPMAP_DIRS[2]}/ic_launcher.png\"\n          convert \"$ICON_SOURCE\" -resize 144x144 \"${MIPMAP_DIRS[3]}/ic_launcher.png\"\n          convert \"$ICON_SOURCE\" -resize 192x192 \"${MIPMAP_DIRS[4]}/ic_launcher.png\"\n          \n          echo \"✅ Android icon set successfully\"\n          \n          echo \"🔨 Building Android APK and AAB...\"\n          pnpm tauri android build --apk --aab\n\n      - name: Decode keystore\n        env:\n          ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}\n        run: |\n          echo \"$ANDROID_KEYSTORE_BASE64\" | base64 -d > src-tauri/android-release.keystore\n          ls -la src-tauri/android-release.keystore\n\n      - name: Get version\n        id: get_version\n        run: |\n          VERSION=$(grep -o '\"version\": *\"[^\"]*\"' src-tauri/tauri.conf.json | head -1 | sed 's/\"version\": *\"\\(.*\\)\"/\\1/')\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"Version: $VERSION\"\n\n      - name: Sign and Rename APK\n        env:\n          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}\n          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}\n        run: |\n          cd src-tauri\n          APK_PATH=\"gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk\"\n          VERSION=\"${{ steps.get_version.outputs.version }}\"\n          SIGNED_APK=\"gen/android/app/build/outputs/apk/universal/release/NoteGen_${VERSION}_android-universal.apk\"\n          \n          if [ ! -f \"$APK_PATH\" ]; then\n            echo \"❌ APK file not found at $APK_PATH\"\n            ls -la gen/android/app/build/outputs/apk/universal/release/ || true\n            exit 1\n          fi\n          \n          echo \"📝 Signing APK with apksigner (V1 + V2 signatures)...\"\n          $ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | tail -n 1)/apksigner sign \\\n            --ks android-release.keystore \\\n            --ks-key-alias note-gen \\\n            --ks-pass pass:\"$ANDROID_KEYSTORE_PASSWORD\" \\\n            --key-pass pass:\"$ANDROID_KEY_PASSWORD\" \\\n            --out \"$SIGNED_APK\" \\\n            \"$APK_PATH\"\n          \n          echo \"✅ APK signed successfully\"\n          \n          # Verify signature\n          echo \"🔍 Verifying APK signature...\"\n          $ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | tail -n 1)/apksigner verify --verbose \"$SIGNED_APK\"\n          \n          # Show file info\n          ls -lh \"$SIGNED_APK\"\n\n      - name: Rename AAB\n        run: |\n          cd src-tauri\n          VERSION=\"${{ steps.get_version.outputs.version }}\"\n          AAB_PATH=\"gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab\"\n          RENAMED_AAB=\"gen/android/app/build/outputs/bundle/universalRelease/NoteGen_${VERSION}_android-universal.aab\"\n          \n          if [ -f \"$AAB_PATH\" ]; then\n            mv \"$AAB_PATH\" \"$RENAMED_AAB\"\n            echo \"✅ AAB renamed to: $RENAMED_AAB\"\n            ls -lh \"$RENAMED_AAB\"\n          fi\n\n      - name: Upload APK as artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: android-apk\n          path: src-tauri/gen/android/app/build/outputs/apk/universal/release/NoteGen_*.apk\n          if-no-files-found: error\n\n      - name: Upload AAB as artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: android-aab\n          path: src-tauri/gen/android/app/build/outputs/bundle/universalRelease/NoteGen_*.aab\n          if-no-files-found: warn\n\n      - name: Upload to Release\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: note-gen-v${{ steps.get_version.outputs.version }}\n          files: |\n            src-tauri/gen/android/app/build/outputs/apk/universal/release/NoteGen_*.apk\n            src-tauri/gen/android/app/build/outputs/bundle/universalRelease/NoteGen_*.aab\n          draft: false\n          prerelease: false\n\n      - name: Cleanup keystore\n        if: always()\n        run: |\n          rm -f src-tauri/android-release.keystore\n\n  upgradeLink-upload-android:\n    needs: build-android\n    permissions:\n      contents: write\n    runs-on: ubuntu-latest\n    steps:\n      - name: Send Android APK to UpgradeLink\n        uses: toolsetlink/upgradelink-action@3.0.2\n        with:\n          access_key: ${{ secrets.UPGRADE_LINK_ACCESS_KEY }}\n          access_secret: ${{ secrets.UPGRADE_LINK_ACCESS_SECRET }}\n          config: |\n            {\n              \"app_type\": \"file\",\n              \"request\": {\n                \"app_key\": \"${{ secrets.UPGRADE_LINK_ANDROID_APP_KEY }}\",\n                \"version\": \"${{ needs.build-android.outputs.appVersion }}\",\n                \"url\": \"https://github.com/${{ github.repository }}/releases/download/note-gen-v${{ needs.build-android.outputs.appVersion }}/NoteGen_${{ needs.build-android.outputs.appVersion }}_android-universal.apk\",\n                \"prompt_upgrade_content\": \"新版本已发布，包含重要功能更新和 bug 修复\"\n              }\n            }\n\n  publish-tauri:\n    outputs:\n      appVersion: ${{ steps.set_output.outputs.appVersion }}\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: 'macos-latest'\n            args: '--target aarch64-apple-darwin'\n          - platform: 'macos-latest'\n            args: '--target x86_64-apple-darwin'\n          - platform: 'ubuntu-24.04'\n            args: '--bundles deb,rpm'\n          - platform: 'windows-latest'\n            args: ''\n\n    runs-on: ${{ matrix.platform }}\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: pnpm/action-setup@v4\n        with:\n          version: 9\n          run_install: true\n\n      - name: setup node\n        uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n          cache: 'pnpm'\n\n      - name: install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}\n\n      - name: install dependencies (ubuntu only)\n        if: matrix.platform == 'ubuntu-24.04'\n        run: |\n          sudo apt-get update\n          sudo apt-get install pkg-config libclang-dev libxcb1-dev libxrandr-dev libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libgbm-dev libappindicator3-dev librsvg2-dev patchelf\n\n      - name: install frontend dependencies\n        run: pnpm install\n\n      - name: Import Apple Certificate\n        if: matrix.platform == 'macos-latest'\n        env:\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD || 'temporary_keychain_password' }}\n        run: |\n          # Create variables\n          CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12\n          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n\n          # Import certificate from secrets\n          echo -n \"$APPLE_CERTIFICATE\" | base64 --decode -o $CERTIFICATE_PATH\n\n          # Create temporary keychain\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n          # Import certificate to keychain\n          security import $CERTIFICATE_PATH -P \"$APPLE_CERTIFICATE_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security list-keychain -d user -s $KEYCHAIN_PATH\n\n          # Enable codesigning from a non user interactive shell\n          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n      - uses: tauri-apps/tauri-action@v0.5.23\n        id: tauri-action\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}\n        with:\n          tagName: note-gen-v__VERSION__\n          releaseName: 'NoteGen v__VERSION__'\n          releaseBody: 'See the assets to download this version and install.'\n          releaseDraft: false\n          prerelease: false\n          args: ${{ matrix.args }}\n\n      \n      - name: Generate release tag\n        id: save_tag\n        if: matrix.platform == 'ubuntu-24.04'\n        run: |\n          # 调试输出\n          echo ${{ steps.tauri-action.outputs.appVersion }}\n          # 输出到步骤级\n          echo \"appVersion=${{ steps.tauri-action.outputs.appVersion }}\" >> $GITHUB_OUTPUT\n\n      - name: Set job output\n        id: set_output\n        if: matrix.platform == 'ubuntu-24.04'\n        run: |\n          # 注意：这里引用的是 save_tag 步骤的 tag_name 输出\n          echo \"appVersion=${{ steps.save_tag.outputs.appVersion }}\" >> $GITHUB_OUTPUT\n\n      - name: Cleanup keychain\n        if: matrix.platform == 'macos-latest' && always()\n        run: |\n          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n          security delete-keychain $KEYCHAIN_PATH || true\n\n  upgradeLink-upload:\n    needs: publish-tauri\n    permissions:\n      contents: write\n    runs-on: ubuntu-latest\n    steps:\n      - name: Send a request to UpgradeLink\n        uses: toolsetlink/upgradelink-action@3.0.2\n        with:\n          access_key: ${{ secrets.UPGRADE_LINK_ACCESS_KEY }}\n          access_secret: ${{ secrets.UPGRADE_LINK_ACCESS_SECRET }}\n          config: |\n            {\n              \"app_type\": \"tauri\",\n              \"request\": {\n                \"app_key\": \"${{ secrets.UPGRADE_LINK_TAURI_KEY }}\",\n                \"latest_json_url\": \"https://github.com/${{ github.repository }}/releases/download/note-gen-v${{ needs.publish-tauri.outputs.appVersion }}/latest.json\"\n              }\n            }\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n\n./dist\ndist-ssr\nsrc-tauri/gen/*\n!src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/\n*.local\ntarget/*\n.claude/*\n.superpowers/*\n.agents/*\n/docs/*/*\nskills-lock.json\n\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n*.test.*\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# Android signing keys\n*.keystore\n*.jks\nandroid-app.keystore\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\ndocs/.vitepress/dist\ndocs/.vitepress/cache\n\n.idea\n\n# Exclude all markdown files in root directory except README.md\n/*.md\n!README.md\n\n# backup files\n*.backup\n.backup\n\n# worktrees\n.worktrees\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"marscode.codeCompletionPro\": {\n    \"enableCodeCompletionPro\": false\n  },\n  \"marscode.enableInlineCommand\": false,\n  \"i18n-ally.localesPaths\": [\n    \"messages\",\n    \"src/i18n\"\n  ]\n}"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2025 codexu, https://notegen.top/.\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# NoteGen\n\n![](https://img.shields.io/badge/free-pricing?logo=free&color=%20%23155EEF&label=pricing&labelColor=%20%23528bff)\n[![GitHub Repo stars](https://img.shields.io/github/stars/codexu/note-gen)](https://github.com/codexu/note-gen)\n[![](https://gitcode.com/codexu/note-gen/star/badge.svg)](https://gitcode.com/codexu/note-gen)\n![](https://github.com/codexu/note-gen/actions/workflows/release.yml/badge.svg?branch=release)\n[![Netlify Status](https://api.netlify.com/api/v1/badges/8f7518c3-b627-4277-bc2f-e477960f5dc4/deploy-status)](https://app.netlify.com/projects/note-gen-docs/deploys)\n![](https://img.shields.io/github/downloads/codexu/note-gen/total)\n![](https://img.shields.io/github/issues-closed/codexu/note-gen)\n\n<div>\n  <a href=\"https://trendshift.io/repositories/12784\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/12784\" alt=\"codexu%2Fnote-gen | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n  <a href=\"https://hellogithub.com/repository/0163cb946dca44cc8905dbe34c2c987b\" target=\"_blank\"><img src=\"https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=0163cb946dca44cc8905dbe34c2c987b&claim_uid=YJ39kIMBz1TGAvc\" alt=\"Featured｜HelloGitHub\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n  <a href=\"https://www.producthunt.com/products/notegen-2?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-notegen&#0045;2\" target=\"_blank\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=956348&theme=light&t=1749194675492\" alt=\"NoteGen - A&#0032;cross&#0045;platform&#0032;Markdown&#0032;note&#0045;taking&#0032;application | Product Hunt\" style=\"width: 250px; height: 54px;\" width=\"250\" height=\"54\" /></a>\n</div>\n\n## 我正在寻找工作 / Looking For Work\n\n我是一名全栈（侧重前端）开发者，目前正在寻找新的工作机会，欢迎联系我！\n\nI am a full-stack (with a focus on front-end) developer, currently looking for new job opportunities. Feel free to contact me! \n\n我希望可以获取一份远程办公的工作，或在北京的公司上班。\n\nI am looking for a remote job or a position in a company based in Beijing.\n\nEmail: xu461229187@gmail.com\n\n## Guide\n\n🖥️ Official Document: [English](https://notegen.top/en/) | [简体中文](https://notegen.top/cn/)\n\n💬 Join [WeChat/QQ Group](https://github.com/codexu/note-gen/discussions/110), [Discord](https://discord.gg/SXyVZGpbpk), [Telegram](https://t.me/notegen)\n\nNoteGen is a cross-platform `Markdown` note-taking application dedicated to using AI to bridge recording and writing, organizing fragmented knowledge into a readable note.\n\n![](https://s2.loli.net/2025/12/22/jlpEP2c6ogwHhIA.png)\n\n## Features\n\n- 🚀 Lightweight (25MB), free, no ads.\n- 🌐 Cross-platform support.\n- 🆓 Free AI and sync solutions.\n- 📦 Out-of-the-box RAG support.\n- 🔌 MCP support for AI tool integration.\n- 🤖 Intelligent agents for automated note processing.\n- ✍️ Quick note-taking for fragmented information.\n- 📝 Native Markdown storage format.\n\n## Download\n\n| ![Windows](https://img.shields.io/badge/Windows-0078D4?logo=windows&logoColor=white&style=for-the-badge) | ![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=white&style=for-the-badge) | ![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black&style=for-the-badge) | ![Android](https://img.shields.io/badge/Android-3DDC84?logo=android&logoColor=white&style=for-the-badge) | ![iOS](https://img.shields.io/badge/iOS-000000?logo=apple&logoColor=white&style=for-the-badge) |\n| --- | --- | --- | --- | --- |\n| ✅ beta | ✅ beta | ✅ beta | 🛠️ alpha | 🛠️ alpha |\n| [Download](https://notegen.top/en/docs/download#desktop-beta) | [Download](https://notegen.top/en/docs/download#desktop-beta) | [Download](https://notegen.top/en/docs/download#desktop-beta) | [Download](https://notegen.top/en/docs/download#android) | [TestFlight](https://testflight.apple.com/join/8KjFRTCq) |\n\n> [UpgradeLink offers application upgrade and download services](http://upgrade.toolsetlink.com/upgrade/example/tauri-example.html)\n\n## From Recording to Writing\n\nTraditional note-taking apps typically don't offer note-taking functionality, but NoteGen makes it easier for you to record scattered knowledge points and avoid disrupting your train of thought while taking notes.\n\nNoteGen is divided into three parts: Recording, Notes, and AI Dialogue. They have the following features: \n\n- You don't need to consider the order and logic of recording, AI will help you organize the notes into well-organized and coherent ones.\n- AI Dialogue is a feature that allows you to interact with AI in real-time, helping you to better understand and remember the content you are recording.\n- The note-taking feature can help you optimize the details of your notes independently.\n\n## Contribute\n\n- [Read contribution guide](https://notegen.top/en/docs/contributing)\n- [Update plans](https://github.com/codexu/note-gen/issues/46)\n- [Submit bugs or improvement suggestions](https://github.com/codexu/note-gen/issues)\n- [Discussions](https://github.com/codexu/note-gen/discussions)\n\n## Contributors\n\n<a href=\"https://github.com/codexu/note-gen/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=codexu/note-gen\" />\n</a>\n\n## Thanks\n\nSpecial thanks to our technology partners who make NoteGen better:\n\n**[SiliconFlow](https://cloud.siliconflow.cn/i/O2ciJeZw)** - Providing free AI model services, powering NoteGen's intelligent features with high-quality AI capabilities.\n\n<a href=\"https://cloud.siliconflow.cn/i/O2ciJeZw\" target=\"_blank\">\n  <img width=\"240\" src=\"https://s2.loli.net/2025/09/10/KWPOA5XhIGmYTV9.png\" />\n</a>\n\n**[UpgradeLink](http://upgrade.toolsetlink.com/upgrade/example/tauri-example.html)** - Providing reliable installation and upgrade services, ensuring seamless software updates for users.\n\n<a href=\"http://upgrade.toolsetlink.com/upgrade/example/tauri-example.html\" target=\"_blank\">\n  <img width=\"240\" src=\"https://s2.loli.net/2025/09/10/Ks4EayU9HguXDMF.png\" />\n</a>\n\n---\n\nWe also thank other partners for their service support\n\n<div>\n  <a href=\"https://www.qiniu.com/products/ai-token-api?utm_source=NoteGen\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/06/11/OKJq542lTs7U9xg.png\" />\n  </a>\n  <a href=\"https://share.302.ai/jfFrIP\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/07/01/dPlkU1tejnDyV4S.png\" />\n  </a>\n  <a href=\"https://www.shengsuanyun.com/?from=CH_KAFLGC9O\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/09/15/CcVRbTUBtf7ZvNl.png\" />\n  </a>\n  <a href=\"https://ai.gitee.com/\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/09/15/wmnBWfyACMz9pVc.png\" />\n  </a>\n  <a href=\"https://www.netlify.com\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/09/16/yJ64xIlrhdABt9o.png\" />\n  </a>\n  <a href=\"https://skywork.ai/p/bY47ky\" target=\"_blank\">\n    <img src=\"https://s2.loli.net/2025/09/16/mTzMCQ8tZLfJNk5.png\" />\n  </a>\n</div>\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"src/app/globals.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"iconLibrary\": \"lucide\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"registries\": {\n    \"@magicui\": \"https://magicui.design/r/{name}.json\"\n  }\n}\n"
  },
  {
    "path": "messages/en.json",
    "content": "{\n  \"app\": {\n    \"title\": \"Note Generator\",\n    \"description\": \"Your AI-powered note taking assistant\"\n  },\n  \"common\": {\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"create\": \"Create\",\n    \"theme\": \"Theme\",\n    \"light\": \"Light\",\n    \"dark\": \"Dark\",\n    \"system\": \"System\",\n    \"pin\": \"Pin\",\n    \"unpin\": \"Unpin\",\n    \"settings\": \"Settings\",\n    \"back\": \"Back\",\n    \"sync\": \"Sync\",\n    \"language\": \"Language\",\n    \"confirm\": \"Confirm\",\n    \"selectPrompt\": \"Select Prompt\",\n    \"prompt\": \"Prompt\",\n    \"success\": \"Success\",\n    \"error\": \"Failed\",\n    \"defaultFileName\": \"Untitled\",\n    \"restartToApply\": \", please restart the application for the configuration to take effect\",\n    \"unsaved\": \"Unsaved\",\n    \"saving\": \"Saving...\",\n    \"close\": \"Close\",\n    \"open\": \"Open\",\n    \"add\": \"Add\",\n    \"remove\": \"Remove\",\n    \"search\": \"Search\",\n    \"filter\": \"Filter\",\n    \"sort\": \"Sort\",\n    \"export\": \"Export\",\n    \"import\": \"Import\",\n    \"refresh\": \"Refresh\",\n    \"loading\": \"Loading...\",\n    \"all\": \"All\",\n    \"today\": \"Today\",\n    \"yesterday\": \"Yesterday\",\n    \"warning\": \"Warning\",\n    \"info\": \"Info\",\n    \"configureSync\": \"Configure Sync\"\n  },\n  \"settings\": {\n    \"defaultModels\": {\n      \"title\": \"Default Models\"\n    },\n    \"others\": \"Advanced\",\n    \"general\": {\n      \"title\": \"General Settings\",\n      \"desc\": \"Here, you can configure basic application settings, including interface theme, language and other options.\",\n      \"interface\": {\n        \"title\": \"Interface Settings\",\n        \"theme\": {\n          \"title\": \"Theme\",\n          \"desc\": \"Choose the application's appearance theme\",\n          \"options\": {\n            \"light\": \"Light\",\n            \"dark\": \"Dark\",\n            \"system\": \"System\"\n          }\n        },\n        \"language\": {\n          \"title\": \"Language\",\n          \"desc\": \"Choose the application's display language\"\n        },\n        \"scale\": {\n          \"title\": \"Interface Scale\",\n          \"desc\": \"Adjust the overall scale of the application interface\",\n          \"placeholder\": \"Select scale ratio\"\n        },\n        \"contentTextScale\": {\n          \"title\": \"Content Scale\",\n          \"desc\": \"Adjust the text size in editor and chat Markdown content\"\n        },\n        \"fileManagerTextSize\": {\n          \"title\": \"File Manager Text Size\",\n          \"desc\": \"Adjust the text size of file and folder lists in the file manager\"\n        },\n        \"recordTextSize\": {\n          \"title\": \"Record Text Size\",\n          \"desc\": \"Adjust the text size of record items in the record list\"\n        },\n        \"customCss\": {\n          \"title\": \"Custom CSS\",\n          \"desc\": \"Add custom CSS styles to override the application's default styles\",\n          \"button\": \"Edit CSS\",\n          \"dialogTitle\": \"Custom CSS\",\n          \"dialogDesc\": \"Enter custom CSS code below to override the application's default styles. Click save to apply changes.\",\n          \"placeholder\": \"Enter custom CSS code here\",\n          \"save\": \"Save\",\n          \"cancel\": \"Cancel\"\n        },\n        \"customTheme\": {\n          \"title\": \"Custom Theme Colors\",\n          \"desc\": \"Customize application theme colors including background, foreground, border, etc.\",\n          \"button\": \"Edit Colors\",\n          \"dialogTitle\": \"Custom Theme Colors\",\n          \"dialogDesc\": \"Configure custom theme colors. Color changes are saved and applied in real-time, overriding both light and dark themes.\",\n          \"close\": \"Close\",\n          \"reset\": \"Reset All\",\n          \"tabs\": {\n            \"custom\": \"Custom\",\n            \"presets\": \"Presets\",\n            \"importExport\": \"Import/Export\"\n          },\n          \"export\": {\n            \"title\": \"Export Color Scheme\",\n            \"button\": \"Generate Export Code\",\n            \"placeholder\": \"Click the generate button to export current color scheme as code\"\n          },\n          \"import\": {\n            \"title\": \"Import Color Scheme\",\n            \"button\": \"Import Scheme\",\n            \"placeholder\": \"Paste the JSON code of the color scheme\"\n          },\n          \"colors\": {\n            \"background\": \"Background\",\n            \"foreground\": \"Foreground\",\n            \"card\": \"Card Background\",\n            \"cardForeground\": \"Card Foreground\",\n            \"primary\": \"Primary\",\n            \"primaryForeground\": \"Primary Foreground\",\n            \"secondary\": \"Secondary\",\n            \"secondaryForeground\": \"Secondary Foreground\",\n            \"third\": \"Third\",\n            \"thirdForeground\": \"Third Foreground\",\n            \"muted\": \"Muted\",\n            \"mutedForeground\": \"Muted Foreground\",\n            \"accent\": \"Accent\",\n            \"accentForeground\": \"Accent Foreground\",\n            \"border\": \"Border\",\n            \"shadow\": \"Shadow\"\n          },\n          \"presets\": {\n            \"apply\": \"Apply\",\n            \"reset\": {\n              \"name\": \"Reset to Default\"\n            },\n            \"default\": {\n              \"name\": \"Default White\"\n            },\n            \"ocean\": {\n              \"name\": \"Ocean Blue\"\n            },\n            \"forest\": {\n              \"name\": \"Forest Green\"\n            },\n            \"sunset\": {\n              \"name\": \"Sunset Red\"\n            },\n            \"lavender\": {\n              \"name\": \"Lavender Purple\"\n            },\n            \"midnight\": {\n              \"name\": \"Midnight Dark\"\n            },\n            \"deepSea\": {\n              \"name\": \"Deep Sea\"\n            },\n            \"darkForest\": {\n              \"name\": \"Dark Forest\"\n            },\n            \"darkViolet\": {\n              \"name\": \"Dark Violet\"\n            },\n            \"coralWarm\": {\n              \"name\": \"Coral Warm\"\n            },\n            \"slateGray\": {\n              \"name\": \"Slate Gray\"\n            },\n            \"darkGold\": {\n              \"name\": \"Dark Gold\"\n            },\n            \"beigeWarm\": {\n              \"name\": \"Beige Warm\"\n            },\n            \"beigeDark\": {\n              \"name\": \"Beige Dark\"\n            }\n          }\n        },\n        \"tray\": {\n          \"enabled\": {\n            \"title\": \"Enable Tray\",\n            \"desc\": \"Choose to minimize to tray or close app when window is closed\"\n          }\n        }\n      },\n      \"tools\": {\n        \"title\": \"Tool Settings\",\n        \"desc\": \"Configure display and sorting of various toolbar buttons\",\n        \"chatToolbar\": {\n          \"title\": \"Chat Toolbar\",\n          \"desc\": \"Customize the display order and visibility of chat toolbar buttons\",\n          \"button\": \"Configure\",\n          \"dialogTitle\": \"Configure Chat Toolbar\",\n          \"dialogDesc\": \"Drag tools to adjust order, use switches to show or hide\",\n          \"groups\": {\n            \"pc\": \"PC\",\n            \"mobile\": \"Mobile\",\n            \"bottom\": \"Bottom Toolbar\",\n            \"topLeft\": \"Top Toolbar - Left\",\n            \"topRight\": \"Top Toolbar - Right\"\n          }\n        },\n        \"recordToolbar\": {\n          \"title\": \"Record Toolbar\",\n          \"desc\": \"Customize the display order and visibility of record toolbar buttons\",\n          \"button\": \"Configure\",\n          \"dialogTitle\": \"Configure Record Toolbar\",\n          \"dialogDesc\": \"Drag tools to adjust order, use switches to show or hide\"\n        }\n      }\n    },\n    \"rag\": {\n      \"title\": \"Knowledge Base\",\n      \"desc\": \"Here, you can configure knowledge base related settings, knowledge base based on RAG technology, through embedding models to convert text into vectors, then through vector search to achieve intelligent search and intelligent answers.\",\n      \"settingsTitle\": \"Parameter Settings\",\n      \"settingsDesc\": \"By adjusting parameters, you can more precisely control the retrieval effect of the knowledge base.\",\n      \"deleteVectorConfirm\": \"Are you sure you want to clear the knowledge base?\",\n      \"deleteVectorSuccess\": \"Knowledge base cleared successfully\",\n      \"enable\": \"Enable knowledge base search\",\n      \"enableDesc\": \"Enabling it will make AI search your notes when answering questions, providing more accurate answers.\",\n      \"chunkSize\": \"Chunk size\",\n      \"chunkSizeDesc\": \"The maximum number of characters for text chunking. Larger chunks may contain more context, but will increase vector calculation complexity.\",\n      \"chunkOverlap\": \"Chunk overlap\",\n      \"chunkOverlapDesc\": \"The number of overlapping characters between text chunks. Larger overlaps can maintain context continuity.\",\n      \"resultCount\": \"Result count\",\n      \"resultCountDesc\": \"The number of related documents returned when searching. The more documents, the more information provided, but may also introduce noise.\",\n      \"similarityThreshold\": \"Similarity threshold\",\n      \"similarityThresholdDesc\": \"The minimum similarity threshold between documents and queries. Only documents exceeding this threshold will be returned. The value range is 0.0-1.0, the higher the threshold, the stricter the requirement.\",\n      \"resetToDefaults\": \"Reset to defaults\",\n      \"deleteVector\": \"Clear knowledge base\",\n      \"topPDesc\": \"Top P parameter controls the diversity of text generated by the model. Smaller values make the output more deterministic, while larger values make it more diverse.\"\n    },\n    \"mcp\": {\n      \"title\": \"MCP\",\n      \"desc\": \"Model Context Protocol allows AI to call external tools and access resources, extending AI capabilities.\",\n      \"enableTitle\": \"Enable MCP\",\n      \"enableDesc\": \"When enabled, AI can call tools provided by configured MCP servers.\",\n      \"servers\": \"Server List\",\n      \"serversDesc\": \"Manage MCP server configurations. Each server can provide different tools and resources.\",\n      \"addServer\": \"Add Server\",\n      \"addFirstServer\": \"Add First Server\",\n      \"editServer\": \"Edit Server\",\n      \"serverName\": \"Server Name\",\n      \"serverNamePlaceholder\": \"e.g., File System Server\",\n      \"serverEnabled\": \"Enable Server\",\n      \"serverEnabledDesc\": \"When enabled, this server will automatically connect and provide tools.\",\n      \"serverType\": \"Server Type\",\n      \"stdio\": \"Local Command\",\n      \"http\": \"HTTP Service\",\n      \"command\": \"Command\",\n      \"args\": \"Arguments\",\n      \"argsDesc\": \"Command line arguments, separated by spaces\",\n      \"env\": \"Environment Variables\",\n      \"envDesc\": \"Environment variable configuration in JSON format\",\n      \"url\": \"Service URL\",\n      \"headers\": \"Request Headers\",\n      \"headersDesc\": \"HTTP request headers in JSON format\",\n      \"testConnection\": \"Test Connection\",\n      \"test\": \"Test\",\n      \"testSuccess\": \"Connection test successful\",\n      \"testFailed\": \"Connection test failed\",\n      \"connected\": \"Connected\",\n      \"connecting\": \"Connecting\",\n      \"disconnected\": \"Disconnected\",\n      \"error\": \"Error\",\n      \"tools\": \"Tools\",\n      \"noServers\": \"MCP service not enabled\",\n      \"noServersFound\": \"No matching servers found\",\n      \"serverAdded\": \"Server added successfully\",\n      \"serverUpdated\": \"Server updated successfully\",\n      \"serverDeleted\": \"Server deleted successfully\",\n      \"deleteServerTitle\": \"Delete Server\",\n      \"deleteServerDesc\": \"Are you sure you want to delete this server? This action cannot be undone.\",\n      \"nameRequired\": \"Please enter server name\",\n      \"commandRequired\": \"Please enter command\",\n      \"urlRequired\": \"Please enter service URL\",\n      \"toolBrowser\": \"Tool Browser\",\n      \"searchTools\": \"Search tools...\",\n      \"noToolsFound\": \"No tools found\",\n      \"parameters\": \"Parameters\",\n      \"testAll\": \"Test All Connections\",\n      \"testAllCompleted\": \"All connection tests completed\",\n      \"testAllFailed\": \"Connection test failed\",\n      \"save\": \"Save\",\n      \"cancel\": \"Cancel\",\n      \"delete\": \"Delete\",\n      \"importJson\": \"Import JSON\",\n      \"jsonImportTitle\": \"Import Server Configuration from JSON\",\n      \"jsonImportDesc\": \"Paste the mcpServers configuration format for MCP servers\",\n      \"jsonInput\": \"JSON Configuration\",\n      \"jsonInputHelp\": \"Supports mcpServers format, automatically uses server name as key\",\n      \"jsonRequired\": \"Please enter JSON configuration\",\n      \"jsonEmpty\": \"JSON configuration cannot be empty\",\n      \"jsonInvalidJson\": \"Invalid JSON format\",\n      \"jsonInvalidFormat\": \"Invalid configuration format, must contain name and type fields\",\n      \"jsonInvalidType\": \"Server type must be stdio or http\",\n      \"jsonMissingCommand\": \"stdio type server must specify command\",\n      \"jsonMissingUrl\": \"http type server must specify url\",\n      \"jsonImportSuccess\": \"Successfully imported {count} server(s)\",\n      \"jsonImportSkipped\": \"Skipped {count} existing server(s)\",\n      \"jsonImportNoServers\": \"No servers were imported\",\n      \"import\": \"Import\",\n      \"mobileHttpOnlyTitle\": \"Desktop-only local command MCP\",\n      \"mobileHttpOnlyDesc\": \"Local command MCP servers are only supported on desktop. Mobile currently supports HTTP MCP only.\",\n      \"runtimeEnvironment\": \"Runtime Environment\",\n      \"runtimeEnvironmentDesc\": \"Check whether the required local runtime is available before testing the MCP server.\",\n      \"checkEnvironment\": \"Check Environment\",\n      \"recheckEnvironment\": \"Re-check Environment\",\n      \"runtimeCheckFailed\": \"Environment check failed\",\n      \"detectedLauncher\": \"Detected launcher\",\n      \"runtimeInstalled\": \"Installed\",\n      \"runtimeMissing\": \"Missing\",\n      \"runtimeVersion\": \"Version\",\n      \"runtimeInstalledSummary\": \"{installed}/{total} installed\",\n      \"showRuntimeDetails\": \"Show runtime details\",\n      \"hideRuntimeDetails\": \"Hide runtime details\",\n      \"runtimeNotChecked\": \"This runtime has not been checked yet.\",\n      \"runtimeCurrentUserScope\": \"Recommended command targets the current user environment when supported.\",\n      \"runtimeManualOnly\": \"Automatic install is not available for this runtime on the current platform. Please install it manually and run the check again.\",\n      \"installRuntime\": \"Install Runtime\",\n      \"runtimeInstallTitle\": \"Install runtime\",\n      \"runtimeInstallDesc\": \"NoteGen will run the following install command after confirmation.\",\n      \"runtimeInstallPreparing\": \"Preparing install\",\n      \"runtimeInstallRunning\": \"Installing\",\n      \"runtimeInstallCompleted\": \"Install completed\",\n      \"runtimeInstallCancelled\": \"Cancelled\",\n      \"runtimeInstallFailedState\": \"Install failed\",\n      \"runtimeInstallLogs\": \"Install logs\",\n      \"runtimeInstallWaitingLogs\": \"Waiting for install output...\",\n      \"runtimeInstallClose\": \"Close\",\n      \"runtimeInstallCancel\": \"Stop install\",\n      \"runtimeInstallCancelledByUser\": \"Cancellation requested by user.\",\n      \"runtimeInstallCancelFailed\": \"Failed to stop install\",\n      \"runtimeInstallSuccess\": \"Runtime installation completed\",\n      \"runtimeInstallFailed\": \"Runtime installation failed\",\n      \"runtimeNoGuidedSupport\": \"No guided runtime assistance is available for this command yet.\"\n    },\n    \"skills\": {\n      \"title\": \"Skills\",\n      \"desc\": \"Skills are reusable AI capability packages that allow AI assistants to automatically apply specific behavioral patterns based on tasks.\",\n      \"enable\": \"Enable Skills\",\n      \"enableDesc\": \"When enabled, AI can use configured Skills\",\n      \"autoMatch\": \"Auto-match Skills\",\n      \"autoMatchDesc\": \"Automatically select appropriate Skills based on user input\",\n      \"project\": \"Workspace Skills\",\n      \"global\": \"Global Skills\",\n      \"globalPath\": \"Global Skills Storage Location\",\n      \"openInFileManager\": \"Open in File Manager\",\n      \"createSkill\": \"Create Skill\",\n      \"editSkill\": \"Edit Skill\",\n      \"deleteSkill\": \"Delete Skill\",\n      \"exportSkill\": \"Export Skill\",\n      \"importSkill\": \"Import Skill\",\n      \"selectSkillZip\": \"Select Skill zip file\",\n      \"importSuccess\": \"Import successful\",\n      \"importError\": \"Import failed\",\n      \"imported\": \"imported\",\n      \"importing\": \"Importing...\",\n      \"skillName\": \"Skill Name\",\n      \"skillDescription\": \"Description\",\n      \"skillVersion\": \"Version\",\n      \"skillAuthor\": \"Author\",\n      \"allowedTools\": \"Allowed Tools\",\n      \"userInvocable\": \"Show in Slash Menu\",\n      \"instructions\": \"Instructions\",\n      \"instructionsPlaceholder\": \"Enter detailed instructions for AI...\",\n      \"importHelp\": \"Support importing Skills in zip format. The zip file must contain a SKILL.md file.\",\n      \"metadata\": \"Metadata\",\n      \"content\": \"Instructions\",\n      \"noSkills\": \"No Skills yet\",\n      \"noSkillsDesc\": \"Create or import Skills to get started\",\n      \"noSkillsGlobal\": \"No global Skills yet\",\n      \"noSkillsGlobalDesc\": \"Create or import Skills to use across all projects\",\n      \"emptyWorkspace\": \"No Skills in workspace\",\n      \"emptyWorkspaceDesc\": \"Create SKILL.md files in the skills folder to add Skills\",\n      \"basicSettings\": \"Basic Settings\",\n      \"installedGlobalSkills\": \"Installed Global Skills\",\n      \"nameRequired\": \"Please enter Skill name\",\n      \"descriptionRequired\": \"Please enter description\",\n      \"namePlaceholder\": \"note-organizer\",\n      \"versionPlaceholder\": \"1.0.0\",\n      \"descriptionPlaceholder\": \"Automatically organize and optimize note structure...\",\n      \"authorPlaceholder\": \"Your Name\",\n      \"descriptionHelp\": \"Used for AI matching, describes the Skill's functionality and use cases\",\n      \"allowedToolsHelp\": \"These tools can be used without user confirmation\",\n      \"userInvocableHelp\": \"Users can manually trigger via /skill-name\",\n      \"instructionsHelp\": \"Detailed instructions for AI, supports Markdown format\",\n      \"deleteSkillTitle\": \"Delete Skill\",\n      \"deleteSkillDesc\": \"Are you sure you want to delete this Skill? This action cannot be undone.\",\n      \"skillDeleted\": \"Skill deleted successfully\"\n    },\n    \"editor\": {\n      \"title\": \"Editor Settings\",\n      \"interfaceSettings\": \"Interface Settings\",\n      \"desc\": \"Here, you can customize the editor, creating a writing experience tailored to your needs.\",\n      \"centeredContent\": \"Centered Content\",\n      \"centeredContentDesc\": \"When enabled, editor content will be centered with margins on both sides.\",\n      \"outlineEnable\": \"Default outline enabled\",\n      \"outlineEnableDesc\": \"Enabling it will make the outline visible by default.\",\n      \"outlinePosition\": \"Outline position\",\n      \"outlinePositionDesc\": \"Set outline position.\",\n      \"outlinePositionOptions\": {\n        \"left\": \"Left\",\n        \"right\": \"Right\"\n      },\n      \"showUndoRedo\": \"Undo/Redo buttons\",\n      \"showUndoRedoDesc\": \"Show undo and redo buttons in the editor tab bar.\",\n      \"completion\": {\n        \"title\": \"Auto Completion\",\n        \"model\": {\n          \"title\": \"Quick Completion Model\",\n          \"desc\": \"Select the model for AI inline completion in editor\"\n        }\n      },\n      \"commit\": {\n        \"title\": \"Auto Commit Message\",\n        \"model\": {\n          \"title\": \"Commit Model\",\n          \"desc\": \"For automatically generating Git commit messages based on file changes\"\n        }\n      },\n      \"mermaid\": {\n        \"title\": \"Diagram\",\n        \"rendering\": \"Rendering...\",\n        \"renderError\": \"Render error\",\n        \"clickToEdit\": \"Click to edit source\",\n        \"clickToAdd\": \"Click to add diagram\",\n        \"placeholder\": \"Enter Mermaid diagram code...\",\n        \"preview\": \"Preview\",\n        \"done\": \"Done\",\n        \"diagramTypes\": {\n          \"flowchart\": \"Flowchart\",\n          \"sequence\": \"Sequence\",\n          \"classDiagram\": \"Class Diagram\",\n          \"stateDiagram\": \"State Diagram\",\n          \"er\": \"ER Diagram\",\n          \"gantt\": \"Gantt\",\n          \"pie\": \"Pie Chart\",\n          \"journey\": \"Journey\"\n        },\n        \"templates\": {\n          \"flowchart\": \"graph TD\\n    A[Start] --> B[Process]\\n    B --> C[End]\",\n          \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: Hello\\n    Bob-->>Alice: Reply\",\n          \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n          \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n          \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n          \"gantt\": \"gantt\\n    title Project Plan\\n    dateFormat YYYY-MM-DD\\n    section Phase 1\\n    Task1 :a1, 2024-01-01, 30d\\n    section Phase 2\\n    Task2 :after a1, 20d\",\n          \"pie\": \"pie title Resource Allocation\\n    \\\"CPU\\\" : 45\\n    \\\"Memory\\\" : 30\\n    \\\"Storage\\\" : 25\",\n          \"journey\": \"journey\\n    title My Daily Work\\n    section Morning\\n    Commute : 7:00, 5\\n    Work : 9:00, 8\"\n        }\n      }\n    },\n    \"record\": {\n      \"title\": \"Record Settings\",\n      \"desc\": \"Configure record-related settings here, including record description and toolbar configuration.\",\n      \"model\": {\n        \"title\": \"Model Settings\",\n        \"markDesc\": {\n          \"title\": \"Record Description\",\n          \"desc\": \"For processing OCR-recognized records and generating record descriptions\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"Toolbar Settings\",\n        \"recordToolbar\": {\n          \"title\": \"Record Toolbar\",\n          \"desc\": \"Customize the visibility and order of record toolbar buttons\",\n          \"button\": \"Configure\",\n          \"text\": {\n            \"desc\": \"Record text content\"\n          },\n          \"recording\": {\n            \"desc\": \"Voice recording function\"\n          },\n          \"scan\": {\n            \"desc\": \"Scan and recognize text from images\"\n          },\n          \"image\": {\n            \"desc\": \"Upload images to notes\"\n          },\n          \"link\": {\n            \"desc\": \"Record web links\"\n          },\n          \"file\": {\n            \"desc\": \"Upload files to notes\"\n          },\n          \"todo\": {\n            \"desc\": \"Create todo items\"\n          }\n        }\n      }\n    },\n    \"uploadStore\": {\n      \"uploadConfirm\": \"Upload configuration please ensure the sync repository is private, otherwise the data will be leaked!\",\n      \"downloadConfirm\": \"Download configuration will cover local configuration and restart to take effect!\",\n      \"uploadSuccess\": \"Upload success\",\n      \"downloadSuccess\": \"Download success\",\n      \"upload\": \"Upload\",\n      \"download\": \"Download\"\n    },\n    \"about\": {\n      \"title\": \"About\",\n      \"desc\": \"A note-taking assistant focused on record and writing.\",\n      \"version\": \"NoteGen v{version}\",\n      \"checkReleases\": \"Check Release History\",\n      \"language\": \"Language\",\n      \"checkUpdate\": \"Check for Updates\",\n      \"checkError\": \"Failed to check for updates\",\n      \"updateAvailable\": \"Update to new version\",\n      \"updateDownloading\": \"Updating {downloaded} / {contentLength}\",\n      \"updateInstalled\": \"Restart app\",\n      \"noUpdate\": \"Current version is the latest\",\n      \"ignoreVersion\": \"Ignore this version\",\n      \"ignoreVersionSuccess\": \"This version update has been ignored\",\n      \"items\": {\n        \"home\": {\n          \"title\": \"Home\",\n          \"buttonName\": \"Open\",\n          \"desc\": \"Visit the website to learn more about NoteGen.\"\n        },\n        \"guide\": {\n          \"title\": \"Guide\",\n          \"buttonName\": \"Open\",\n          \"desc\": \"View configuration guide, learn how to configure models, sync, etc. information.\"\n        },\n        \"github\": {\n          \"title\": \"GitHub\",\n          \"buttonName\": \"View\",\n          \"desc\": \"If NoteGen helps you, please give a star to encourage!\"\n        },\n        \"releases\": {\n          \"title\": \"Update Log\",\n          \"buttonName\": \"View\",\n          \"desc\": \"View update log, learn more about NoteGen's updates.\"\n        },\n        \"issues\": {\n          \"title\": \"Issue Feedback\",\n          \"buttonName\": \"Feedback\",\n          \"desc\": \"If you find a bug in NoteGen, please feedback here.\"\n        },\n        \"discussions\": {\n          \"title\": \"Discussions\",\n          \"buttonName\": \"Discuss\",\n          \"desc\": \"If you want to discuss with the author or other users, you can join the group discussion.\"\n        }\n      }\n    },\n    \"memories\": {\n      \"title\": \"Memory Management\",\n      \"desc\": \"AI long-term memory feature that lets AI remember your writing preferences, experiences, and note-taking habits.\",\n      \"stats\": {\n        \"total\": \"Total Memories\",\n        \"preferences\": \"Preferences\",\n        \"memories\": \"Memories\"\n      },\n      \"form\": {\n        \"title\": \"Add New Memory\",\n        \"categoryDescription\": \"Memories are divided into two types:\",\n        \"preferenceDescription\": \"Preferences: Settings like language, format, style - always loaded in conversations\",\n        \"memoryDescription\": \"Memories: Facts, experiences, expertise - intelligently matched based on conversation context\",\n        \"contentLabel\": \"Memory Content\",\n        \"contentPlaceholder\": \"e.g., I prefer Chinese responses, I'm a React expert...\",\n        \"categoryLabel\": \"Type\",\n        \"preferenceLabel\": \"Preference\",\n        \"memoryLabel\": \"Memory\",\n        \"preferenceDesc\": \"Language, format, style, etc.\",\n        \"memoryDesc\": \"Facts, experience, expertise, etc.\",\n        \"save\": \"Save Memory\",\n        \"saving\": \"Saving...\"\n      },\n      \"listTitle\": \"My Memories\",\n      \"addMemory\": \"Add Memory\",\n      \"empty\": \"No memories yet, add your first memory!\",\n      \"emptyHint\": \"You can add memories manually, or use phrases like \\\"please remember\\\" or \\\"remember this\\\" in conversations to let AI automatically create memories.\",\n      \"preference\": \"Preference\",\n      \"memory\": \"Memory\",\n      \"replaced\": \"Replaced\",\n      \"accessCount\": \"Accessed {count} times\",\n      \"tabs\": {\n        \"all\": \"All\",\n        \"preference\": \"Preferences\",\n        \"memory\": \"Memories\"\n      },\n      \"success\": \"Success\",\n      \"saved\": \"Memory saved\",\n      \"updated\": \"Memory updated (similar memory replaced)\",\n      \"deleted\": \"Memory deleted\",\n      \"cleared\": \"All memories cleared\",\n      \"found\": \"Found {count} memories\",\n      \"error\": \"Error\",\n      \"errorEmpty\": \"Please enter memory content\",\n      \"errorSave\": \"Failed to save\",\n      \"errorDelete\": \"Failed to delete\",\n      \"errorList\": \"Failed to get memory list\",\n      \"errorEmbedding\": \"Failed to generate embedding, please check embedding model configuration\",\n      \"errorClear\": \"Failed to clear\"\n    },\n    \"defaultModel\": {\n      \"title\": \"Default Model\",\n      \"desc\": \"Here, you can use different models for different scenes, to improve efficiency and reduce costs.\",\n      \"tooltip\": \"Use main model\",\n      \"noModel\": \"Do not use\",\n      \"placeholder\": \"Please select or search for models\",\n      \"main\": \"Main model\",\n      \"options\": {\n        \"primaryModel\": {\n          \"title\": \"Main model\",\n          \"desc\": \"As the main model for all scenarios, this model is used if other dialogue models do not select the default model.\"\n        },\n        \"markDesc\": {\n          \"title\": \"Record Description\",\n          \"desc\": \"Used to process records after OCR recognition, generating record descriptions.\"\n        },\n        \"placeholder\": {\n          \"title\": \"AI Suggestion\",\n          \"desc\": \"AI suggestion prompt for generating placeholder content in the record page AI conversation.\"\n        },\n        \"completion\": {\n          \"title\": \"Fast Completion\",\n          \"desc\": \"AI inline completion for Markdown editor, similar to GitHub Copilot, quickly generates continuation content.\"\n        },\n        \"commit\": {\n          \"title\": \"Auto Generate Commit Message\",\n          \"desc\": \"Used to automatically generate Git commit messages, intelligently generating descriptive commit messages based on file content changes.\"\n        },\n        \"embedding\": {\n          \"title\": \"Embedding Model\",\n          \"desc\": \"Used for text embedding and vectorization scenarios.\"\n        },\n        \"reranking\": {\n          \"title\": \"Reranking Model\",\n          \"desc\": \"Used for reordering and optimizing search results.\"\n        },\n        \"condense\": {\n          \"title\": \"Condense Model\",\n          \"desc\": \"Used to compress historical conversation content to save token usage\"\n        }\n      },\n      \"mainModel\": \"Main Model\"\n    },\n    \"audio\": {\n      \"title\": \"Audio Settings\",\n      \"desc\": \"Here, you can configure audio settings, including text-to-speech (TTS) and speech-to-text (STT) functions.\",\n      \"mode\": {\n        \"title\": \"Mode\",\n        \"auto\": \"Auto (Recommended)\",\n        \"local\": \"Local only\",\n        \"model\": \"Model only\"\n      },\n      \"tts\": {\n        \"title\": \"Text-to-Speech (TTS)\",\n        \"desc\": \"Configure read aloud functionality to provide voice playback for chat content.\",\n        \"modeDesc\": \"Prefer browser and system voices by default, and use a model only when needed for a better experience.\",\n        \"model\": {\n          \"title\": \"TTS Model\",\n          \"desc\": \"Optional. Configure a model to enhance auto mode or to power model-only mode.\"\n        },\n        \"speed\": {\n          \"title\": \"Speech Rate\",\n          \"desc\": \"Adjust the playback speed of the voice, ranging from 0.5x to 2x speed, with 1x being the normal speed.\"\n        }\n      },\n      \"stt\": {\n        \"title\": \"Speech-to-Text (STT)\",\n        \"desc\": \"Configure voice recognition to convert speech to text records.\",\n        \"modeDesc\": \"Prefer native browser recognition by default, and fall back to a model when local support is unavailable.\",\n        \"model\": {\n          \"title\": \"STT Model\",\n          \"desc\": \"Optional. Configure a model for auto fallback or for model-only mode.\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"Read Aloud\",\n      \"desc\": \"Configure read aloud behavior. System voices are preferred by default, with model speech as an enhancement.\",\n      \"options\": {\n        \"mode\": {\n          \"title\": \"Mode\",\n          \"desc\": \"Auto mode prefers system voices and only tries a model when local speech is unavailable.\",\n          \"auto\": \"Auto (Recommended)\",\n          \"local\": \"Local only\",\n          \"model\": \"Model only\"\n        },\n        \"audioModel\": {\n          \"title\": \"Read Aloud Model\",\n          \"desc\": \"Optional. Configure a model to enhance auto mode or to power model-only mode.\"\n        },\n        \"speed\": {\n          \"title\": \"Speed\",\n          \"desc\": \"Adjust read aloud speed from 0.5x to 2x, with 1x as the default rate.\"\n        }\n      }\n    },\n    \"prompt\": {\n      \"title\": \"Prompt\",\n      \"promptTitle\": \"Prompt Title\",\n      \"desc\": \"Here, you can add and manage prompts, helping AI better understand your needs.\",\n      \"addPrompt\": \"Add Prompt\",\n      \"selectPrompt\": \"Select Prompt\",\n      \"configPrompt\": \"Configure Prompt\",\n      \"noContent\": \"No content\",\n      \"addPromptDesc\": \"Please enter the prompt name and content, helping AI better understand your needs.\",\n      \"promptTitlePlaceholder\": \"Please enter the prompt name\",\n      \"promptContentPlaceholder\": \"Please enter the prompt content\",\n      \"promptContent\": \"Prompt Content\",\n      \"optimizePrompt\": \"Optimize Prompt\",\n      \"optimizing\": \"Optimizing...\",\n      \"optimizeSuccess\": \"Prompt optimized successfully\",\n      \"optimizeFailed\": \"Failed to optimize prompt, please try again later\",\n      \"noContentToOptimize\": \"Please enter prompt content first\"\n    },\n    \"sync\": {\n      \"title\": \"Sync\",\n      \"desc\": \"Here, you can configure the synchronization repository, which can help you synchronize records, markdown files, system configurations and other information.\",\n      \"selectPlatform\": \"Select Sync Platform\",\n      \"platformSettings\": \"Select Platform\",\n      \"settings\": \"Sync Settings\",\n      \"platformDesc\": \"Configure Token and repository information to enable sync\",\n      \"moreSettings\": \"More Settings\",\n      \"repoStatus\": \"Repository Status\",\n      \"syncRepo\": \"Sync Repository\",\n      \"syncRepoDesc\": \"Sync markdown files in writing\",\n      \"imageRepo\": \"Image Repository\",\n      \"imageRepoDesc\": \"Sync your images to repository, using jsdelivr for acceleration\",\n      \"status\": {\n        \"connected\": \"Connected\",\n        \"disconnected\": \"Disconnected\",\n        \"failed\": \"Connection Failed\",\n        \"unconfigured\": \"Not Configured\"\n      },\n      \"uploadRecords\": \"Upload Records & Config\",\n      \"downloadConfig\": \"Download Records & Config\",\n      \"cloudSync\": \"Records & Config Sync\",\n      \"localBackupAll\": \"Local Backup (All)\",\n      \"private\": \"Private\",\n      \"public\": \"Public\",\n      \"createdAt\": \"Created {time}\",\n      \"updatedAt\": \"Last updated {time}\",\n      \"newToken\": \"Create Access Token\",\n      \"newTokenDesc\": \"When creating a new token, please make sure to check the repo permission, and after configuration, it will automatically create a file repository (private) and an image repository.\",\n      \"giteeTokenDesc\": \"Gitee personal access token is used for data synchronization. It needs repository read and write permissions. After configuration, it will automatically create a file repository (private) and an image repository.\",\n      \"imageRepoSetting\": \"Enable Image Hosting\",\n      \"imageRepoSettingDesc\": \"You have already configured an image repository, you can choose to use the image repository or use local storage.\",\n      \"jsdelivrSetting\": \"jsDelivr\",\n      \"autoSyncDesc\": \"When enabled, the editor will automatically sync to GitHub 10 seconds after input stops\",\n      \"giteeAutoSyncDesc\": \"When enabled, the editor will automatically sync to Gitee 10 seconds after input stops\",\n      \"customSyncRepo\": \"Custom Sync Repository Name\",\n      \"customSyncRepoDesc\": \"Leave empty to use default repository name\",\n      \"customImageRepo\": \"Custom Image Repository Name\",\n      \"customImageRepoDesc\": \"Leave empty to use default repository name\",\n      \"backupMethod\": \"Backup Method\",\n      \"backupMethodDesc\": \"After setting as the primary backup method, all sync-related functions in writing will use the current backup method (except for image hosting)\",\n      \"createRepo\": \"Create Repository\",\n      \"creating\": \"Creating\",\n      \"checkRepo\": \"Check Repository\",\n      \"checking\": \"Checking\",\n      \"enterToken\": \"Please enter Access Token\",\n      \"enterTokenHint\": \"Please enter Access Token first to check repository status\",\n      \"defaultRepoName\": \"Default: {name}\",\n      \"gitlabInstanceType\": \"GitLab Instance Type\",\n      \"gitlabInstanceTypeDesc\": \"Select the type of GitLab instance to connect to\",\n      \"gitlabInstanceTypePlaceholder\": \"Select GitLab Instance Type\",\n      \"gitlabInstanceTypeOptions\": {\n        \"selfHosted\": \"Self-hosted Instance\",\n        \"selfHostedDesc\": \"Enter your self-hosted GitLab server address (e.g., https://gitlab.example.com)\"\n      },\n      \"gitlabAccessTokenDesc\": \"Create a personal access token at {instanceDisplayName}, requires api permission\",\n      \"giteaInstanceType\": \"Gitea Instance Type\",\n      \"giteaInstanceTypeDesc\": \"Select the type of Gitea instance to connect to\",\n      \"giteaInstanceTypePlaceholder\": \"Select Gitea Instance Type\",\n      \"giteaInstanceTypeOptions\": {\n        \"selfHosted\": \"Self-hosted Instance\",\n        \"selfHostedDesc\": \"Enter your self-hosted Gitea server address (e.g., https://gitea.example.com)\"\n      },\n      \"giteaAccessTokenDesc\": \"Create a personal access token at {instanceDisplayName}, requires full repository permission\",\n      \"s3\": {\n        \"title\": \"S3 Sync\",\n        \"description\": \"Sync your notes using S3-compatible storage\",\n        \"status\": \"Connection Status\",\n        \"connected\": \"Connected\",\n        \"connecting\": \"Connecting\",\n        \"disconnected\": \"Disconnected\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"Please enter Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"Please enter Secret Access Key\",\n        \"region\": \"Region\",\n        \"bucket\": \"Bucket\",\n        \"bucketPlaceholder\": \"Please enter bucket name\",\n        \"endpoint\": \"Endpoint\",\n        \"pathPrefix\": \"Path Prefix\",\n        \"pathPrefixPlaceholder\": \"Please enter path prefix\",\n        \"pathPrefixDesc\": \"Used to differentiate files between different users, similar to repository name\",\n        \"customDomain\": \"Custom Domain\",\n        \"testConnection\": \"Test Connection\",\n        \"testing\": \"Testing\",\n        \"saveConfig\": \"Save Config\",\n        \"saving\": \"Saving\"\n      },\n      \"webdav\": {\n        \"title\": \"WebDAV Sync\",\n        \"description\": \"Sync your notes using WebDAV protocol\",\n        \"status\": \"Connection Status\",\n        \"connected\": \"Connected\",\n        \"connecting\": \"Connecting\",\n        \"disconnected\": \"Disconnected\",\n        \"url\": \"Server URL\",\n        \"urlPlaceholder\": \"Please enter WebDAV server URL\",\n        \"urlDesc\": \"Supports Synology, QNAP, Nextcloud and other WebDAV services\",\n        \"username\": \"Username\",\n        \"usernamePlaceholder\": \"Please enter username\",\n        \"password\": \"Password\",\n        \"passwordPlaceholder\": \"Please enter password\",\n        \"pathPrefix\": \"Path Prefix\",\n        \"pathPrefixPlaceholder\": \"Please enter path prefix\",\n        \"pathPrefixDesc\": \"Used to differentiate files between different users\",\n        \"testConnection\": \"Test Connection\",\n        \"testing\": \"Testing\",\n        \"saveConfig\": \"Save Config\",\n        \"saving\": \"Saving\"\n      },\n      \"autoSync\": \"Auto Sync\",\n      \"autoSyncOptions\": {\n        \"placeholder\": \"Select auto sync time\",\n        \"disabled\": \"Disabled\",\n        \"2s\": \"2 seconds\",\n        \"3s\": \"3 seconds\",\n        \"5s\": \"5 seconds\",\n        \"10s\": \"10 seconds\",\n        \"20s\": \"20 seconds\",\n        \"30s\": \"30 seconds\",\n        \"1m\": \"1 minute\",\n        \"2m\": \"2 minutes\"\n      },\n      \"autoPullOnOpen\": \"Auto pull when opening files\",\n      \"autoPullOnOpenDesc\": \"When opening a file, automatically pull remote version if newer\",\n      \"autoPullOnSwitch\": \"Auto pull when switching files\",\n      \"autoPullOnSwitchDesc\": \"When switching to another file, automatically pull remote version if newer\",\n      \"exclusions\": {\n        \"title\": \"Sync Exclusion Configuration\",\n        \"desc\": \"The following settings will not be synced across devices as they are device-specific\",\n        \"workspacePath\": \"Workspace Path\",\n        \"workspaceHistory\": \"Workspace History\",\n        \"assetsPath\": \"Assets Path\",\n        \"uiScale\": \"UI Scale\",\n        \"contentTextScale\": \"Content Text Scale\",\n        \"customCss\": \"Custom CSS\",\n        \"reason\": \"These settings may differ across devices, excluding them from sync prevents path errors and other issues\"\n      },\n      \"settingsSync\": {\n        \"uploadSuccess\": \"Settings uploaded successfully\",\n        \"uploadFailed\": \"Failed to upload settings\",\n        \"downloadSuccess\": \"Settings downloaded successfully\",\n        \"downloadFailed\": \"Failed to download settings\",\n        \"autoSync\": \"Settings will be automatically synced during upload/download (excluding device-specific settings like workspace path)\"\n      },\n      \"jsdelivrSettingDesc\": \"Use jsdelivr to accelerate image access.\"\n    },\n    \"imageHosting\": {\n      \"title\": \"Image Hosting\",\n      \"desc\": \"Here, you can configure image hosting services for storing and managing your images.\",\n      \"type\": \"Select Platform\",\n      \"typeDesc\": \"Select image hosting service provider\",\n      \"customRepoName\": \"Custom Repository Name\",\n      \"customRepoNameDesc\": \"Leave empty to use default repository name\",\n      \"isPrimaryBackup\": \"Current {type} primary image hosting method\",\n      \"setPrimaryBackup\": \"Set as Primary Image Hosting\",\n      \"smms\": {\n        \"token\": {\n          \"desc\": \"Please create and input SM.MS Token.\",\n          \"createToken\": \"Create Token\"\n        },\n        \"disk\": \"Disk Usage\",\n        \"error\": \"Failed to get, please check network or Token is correct.\"\n      },\n      \"picgo\": {\n        \"desc\": \"PicGo server URL\",\n        \"ok\": \"Service is running, please ensure PicGo image hosting is configured.\",\n        \"error\": \"Service is not running, please ensure PicGo (v2.2.0+) is running, otherwise image upload will fail.\"\n      },\n      \"github\": {\n        \"title\": \"GitHub Image Hosting\",\n        \"description\": \"Use GitHub repository as image storage service\",\n        \"repoStatus\": \"Repository Status\",\n        \"repoExists\": \"Repository Exists\",\n        \"repoNotExists\": \"Repository Not Found\",\n        \"checking\": \"Checking\",\n        \"creating\": \"Creating\",\n        \"manualCreateTitle\": \"Manual Repository Creation Required\",\n        \"manualCreateDesc\": \"Please follow these steps to create the image hosting repository:\",\n        \"createSteps\": {\n          \"step1\": \"Visit GitHub and log in to your account\",\n          \"step2\": \"Click the \\\"+\\\" button in the top right corner, select \\\"New repository\\\"\",\n          \"step3\": \"Set repository name to:\",\n          \"step4\": \"Optionally set as private repository (recommended)\",\n          \"step5\": \"Click \\\"Create repository\\\" to complete creation\",\n          \"step6\": \"After creation, click the \\\"Recheck\\\" button below\"\n        },\n        \"createNewRepo\": \"Create New Repository\",\n        \"recheckRepo\": \"Recheck\",\n        \"recheckingRepo\": \"Checking...\",\n        \"createRepo\": \"Create Repository\",\n        \"creatingRepo\": \"Creating...\"\n      },\n      \"s3\": {\n        \"title\": \"S3 Object Storage\",\n        \"description\": \"Configure AWS S3 or S3-compatible object storage service as image hosting\",\n        \"status\": \"Connection Status\",\n        \"connected\": \"Connected\",\n        \"connecting\": \"Connecting\",\n        \"disconnected\": \"Disconnected\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"Enter Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"Enter Secret Access Key\",\n        \"region\": \"Region\",\n        \"bucket\": \"Bucket\",\n        \"bucketPlaceholder\": \"Enter bucket name\",\n        \"advancedSettings\": \"Advanced Settings\",\n        \"endpoint\": \"Custom Endpoint\",\n        \"endpointDesc\": \"Leave empty for AWS S3, or enter S3-compatible service endpoint\",\n        \"customDomain\": \"Custom Domain\",\n        \"customDomainDesc\": \"Optional, custom domain for accessing images\",\n        \"pathPrefix\": \"Path Prefix\",\n        \"pathPrefixDesc\": \"Optional, path prefix for image storage\",\n        \"save\": \"Save Configuration\",\n        \"test\": \"Test Connection\",\n        \"setAsPrimary\": \"Set as Primary\",\n        \"error\": \"Configuration Error\",\n        \"requiredFields\": \"Please fill in required fields: Access Key ID, Secret Access Key, Region and Bucket\",\n        \"saveSuccess\": \"Configuration Saved\",\n        \"saveSuccessDesc\": \"S3 configuration has been saved\",\n        \"saveError\": \"Failed to Save Configuration\",\n        \"testSuccess\": \"Connection Test Successful\",\n        \"testSuccessDesc\": \"S3 connection is working, ready to upload images\",\n        \"testFailed\": \"Connection Test Failed\",\n        \"testFailedDesc\": \"Please check configuration and network connection\",\n        \"testFirstDesc\": \"Please test connection successfully before setting as primary\",\n        \"setPrimarySuccess\": \"Set Successfully\",\n        \"setPrimarySuccessDesc\": \"S3 has been set as primary image hosting\"\n      }\n    },\n    \"imageMethod\": {\n      \"title\": \"Image Recognition\",\n      \"desc\": \"Here, you can configure image recognition related settings, supporting OCR and VLM two ways.\",\n      \"enable\": {\n        \"title\": \"Enable Image Recognition\",\n        \"desc\": \"When enabled, images will be automatically recognized during screenshot and image recording. When disabled, image recognition will be skipped.\"\n      },\n      \"setPrimary\": \"Set as default\",\n      \"isPrimary\": \"{type} has been set as default\",\n      \"ocr\": {\n        \"title\": \"OCR\",\n        \"languagePacks\": \"Language Pack\",\n        \"checkModels\": \"Here you can search all models\",\n        \"modelInstruction\": \"Comma separated, for example: eng,chi_sim\"\n      },\n      \"vlm\": {\n        \"title\": \"Visual Language Model\",\n        \"desc\": \"Use visual language model to recognize image content.\"\n      }\n    },\n    \"backupSync\": {\n      \"title\": \"Backup Data\",\n      \"desc\": \"Here, you can use other methods to backup your data, you can regularly backup to ensure data security.\",\n      \"localBackup\": {\n        \"tabTitle\": \"Local Backup\",\n        \"export\": {\n          \"title\": \"Export Backup\",\n          \"desc\": \"Package application data into a .zip file and save to specified location.\",\n          \"button\": \"Choose Location and Export\",\n          \"simpleButton\": \"Export\",\n          \"exporting\": \"Exporting...\"\n        },\n        \"import\": {\n          \"title\": \"Import Backup\",\n          \"desc\": \"Restore application data from .zip file, will overwrite all current data.\",\n          \"button\": \"Choose File and Import\",\n          \"importing\": \"Importing...\",\n          \"warning\": \"Import operation will overwrite all current data, please ensure important content is backed up!\"\n        },\n        \"exportDialog\": {\n          \"title\": \"Choose backup file save location\"\n        },\n        \"importDialog\": {\n          \"title\": \"Choose backup file to import\"\n        },\n        \"exportSuccess\": \"Backup exported successfully!\",\n        \"exportError\": \"Backup export failed\",\n        \"importSuccess\": \"Backup imported successfully! Application will restart to apply changes.\",\n        \"importError\": \"Backup import failed\",\n        \"restartConfirm\": \"Import completed! Restart application now to apply changes?\"\n      }\n    },\n    \"template\": {\n      \"title\": \"Template\",\n      \"desc\": \"Here you can create and manage custom organization templates to help AI organize record content according to your needs.\",\n      \"customTemplate\": \"Custom Template\",\n      \"addTemplate\": \"Add Custom Template\",\n      \"deleteConfirm\": \"Are you sure to delete this template?\",\n      \"status\": \"Status\",\n      \"name\": \"Name\",\n      \"content\": \"Content\",\n      \"scope\": \"Scope\",\n      \"selectScope\": \"Select Scope\",\n      \"addTemplateDesc\": \"Please enter the custom template name and content, helping AI better understand your needs.\",\n      \"editTemplate\": \"Edit Custom Template\",\n      \"noContent\": \"No content\",\n      \"range\": {\n        \"all\": \"All\",\n        \"today\": \"Today\",\n        \"week\": \"Past Week\",\n        \"month\": \"Past Month\",\n        \"threeMonth\": \"Past 3 Months\",\n        \"year\": \"Past Year\"\n      }\n    },\n    \"shortcut\": {\n      \"title\": \"Shortcuts\",\n      \"screenshot\": \"Screenshot Record\",\n      \"link\": \"Link Record\",\n      \"textRecord\": \"Text Record\",\n      \"windowPin\": \"Window Pin\"\n    },\n    \"theme\": {\n      \"title\": \"Theme Settings\",\n      \"desc\": \"Customize application theme colors\",\n      \"appTheme\": \"App Color Scheme\",\n      \"previewTheme\": \"Preview Content Theme\",\n      \"codeTheme\": \"Code Block Highlight Theme\",\n      \"selectTheme\": \"Select Theme\"\n    },\n    \"dev\": {\n      \"title\": \"Developer\",\n      \"desc\": \"Here you can configure developer options, including network proxy, data cleanup, and configuration file management.\",\n      \"clearData\": \"Clear Data\",\n      \"clearDataConfirm\": \"Are you sure to clear data?\",\n      \"proxy\": \"Proxy, used to solve network problems, after configuration, it is recommended to restart the application.\",\n      \"proxyPlaceholder\": \"Enter proxy address\",\n      \"proxyTitle\": \"Network Proxy\",\n      \"clearDataTitle\": \"Clear Data\",\n      \"clearDataDesc\": \"Clear data information, including system configuration and database (including records).\",\n      \"clearFileTitle\": \"Clear Files\",\n      \"clearFileDesc\": \"Clear files, including images and articles.\",\n      \"clearButton\": \"Clear\",\n      \"configFileTitle\": \"Config File Management\",\n      \"configFileDesc\": \"Import and export configuration files. Importing will overwrite current configuration and take effect after restart.\",\n      \"importConfigTitle\": \"Import Config File\",\n      \"exportConfigTitle\": \"Export Config File\",\n      \"importConfigSuccessMobile\": \"Config downloaded successfully, please restart the app manually\",\n      \"exportConfigSuccess\": \"Export successful\",\n      \"importButton\": \"Import\",\n      \"exportButton\": \"Export\"\n    },\n    \"chat\": {\n      \"title\": \"Chat Settings\",\n      \"desc\": \"Configure chat-related settings here, including summary generation.\",\n      \"primaryModel\": {\n        \"title\": \"Primary Model\",\n        \"model\": {\n          \"title\": \"Primary Chat Model\",\n          \"desc\": \"Select the main AI model for daily conversations\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"Toolbar Settings\",\n        \"chatToolbar\": {\n          \"title\": \"Chat Toolbar\",\n          \"desc\": \"Customize the visibility and order of chat toolbar buttons\",\n          \"button\": \"Configure\",\n          \"modelSelect\": {\n            \"desc\": \"Switch the AI model for conversation\"\n          },\n          \"promptSelect\": {\n            \"desc\": \"Select the preset prompt for the conversation\"\n          },\n          \"chatLanguage\": {\n            \"desc\": \"Set the language for the conversation\"\n          },\n          \"chatLink\": {\n            \"title\": \"Link Tag\",\n            \"desc\": \"Link note content of the current tag to conversation context\"\n          },\n          \"fileLink\": {\n            \"desc\": \"Link files or folders to the conversation context\"\n          },\n          \"mcpButton\": {\n            \"desc\": \"Select and connect MCP servers to use external tools\"\n          },\n          \"ragSwitch\": {\n            \"title\": \"Knowledge Base\",\n            \"desc\": \"Enable vector knowledge base retrieval\"\n          },\n          \"clipboardMonitor\": {\n            \"title\": \"Clipboard Monitor\",\n            \"desc\": \"Automatically monitor clipboard content changes\"\n          },\n          \"newChat\": {\n            \"desc\": \"Start a new conversation\"\n          },\n          \"clearContext\": {\n            \"desc\": \"Clear conversation context, keep chat history\"\n          },\n          \"clearChat\": {\n            \"desc\": \"Delete all chat records\"\n          }\n        }\n      },\n      \"condense\": {\n        \"title\": \"Conversation Summary\",\n        \"enable\": {\n          \"title\": \"Enable Summary\",\n          \"desc\": \"Automatically compress long conversations to save token usage\"\n        },\n        \"model\": {\n          \"title\": \"Summary Model\",\n          \"desc\": \"Select the AI model for generating summaries\",\n          \"placeholder\": \"Use primary model\"\n        },\n        \"threshold\": {\n          \"title\": \"Trigger Threshold\",\n          \"desc\": \"Check compression when AI messages exceed this count\"\n        },\n        \"minToken\": {\n          \"title\": \"Min Token Count\",\n          \"desc\": \"Only compress messages exceeding this token count\"\n        },\n        \"keepLatest\": {\n          \"title\": \"Keep Latest\",\n          \"desc\": \"Keep the latest N AI messages uncompressed\"\n        },\n        \"maxLength\": {\n          \"title\": \"Max Summary Length\",\n          \"desc\": \"Control the maximum word count of generated summaries\"\n        },\n        \"prompt\": {\n          \"title\": \"Custom Summary Prompt\",\n          \"desc\": \"Customize the prompt template for generating summaries\",\n          \"label\": \"Prompt Template\",\n          \"placeholder\": \"Enter custom prompt...\",\n          \"help\": \"Use {content} as a placeholder for original content\",\n          \"save\": \"Save\",\n          \"reset\": \"Reset to Default\"\n        }\n      },\n      \"conversationTitle\": {\n        \"title\": \"Conversation Title\",\n        \"model\": {\n          \"title\": \"Title Generation Model\",\n          \"desc\": \"Select the AI model for generating conversation titles\"\n        }\n      },\n      \"inspiration\": {\n        \"title\": \"Inspiration Model\",\n        \"model\": {\n          \"title\": \"Quick Prompt Generator\",\n          \"desc\": \"Generate quick prompt suggestions to help users start conversations\"\n        }\n      }\n    },\n    \"ai\": {\n      \"title\": \"Model Management\",\n      \"desc\": \"Here, you can add and manage various custom model services. After configuration, you will unlock AI-related features, such as organization and conversation functions.\",\n      \"modelTitle\": \"Custom Name\",\n      \"modelConfigTitle\": \"Model Config\",\n      \"modelConfigDesc\": \"Every configuration corresponds to an AI model, you can create new configurations through templates or custom.\",\n      \"providerInfo\": \"Provider Information\",\n      \"providerInfoDesc\": \"This configuration is created based on a provider template, with name and URL pre-configured.\",\n      \"create\": \"Create\",\n      \"createDesc\": \"Select an empty configuration or create a new configuration using the supplier template.\",\n      \"createSection\": {\n        \"title\": \"Custom Model Configuration\",\n        \"descWithoutModels\": \"Add custom AI model configurations to use more powerful model services.\"\n      },\n      \"config\": \"Config\",\n      \"custom\": \"Custom\",\n      \"addCustomModel\": \"Custom\",\n      \"deleteCustomModel\": \"Delete\",\n      \"deleteCustomModelConfirm\": \"Are you sure to delete this custom model?\",\n      \"copyConfig\": \"Copy\",\n      \"builtin\": \"Built-in\",\n      \"modelSupport\": \"Only supports AI models with OpenAI protocol\",\n      \"apiKeyUrl\": \"Create API Key\",\n      \"modelType\": {\n        \"title\": \"Model Type\",\n        \"desc\": \"Select the type of AI model based on its capability\",\n        \"chat\": \"Chat\",\n        \"image\": \"Image\",\n        \"video\": \"Video\",\n        \"tts\": \"Text-to-Speech\",\n        \"stt\": \"Speech-to-Text\",\n        \"embedding\": \"Embedding\",\n        \"rerank\": \"Reranking\"\n      },\n      \"modelList\": {\n        \"error\": {\n          \"title\": \"Failed to get model list\",\n          \"description\": \"Please check if API Key or network is correct\"\n        }\n      },\n      \"selectModel\": \"Please select a model\",\n      \"modelProviderDesc\": \"Custom models only support AI models with OpenAI protocol.\",\n      \"modelTitleDesc\": \"Custom name, used to identify AI models, please do not repeat.\",\n      \"modelBaseUrlDesc\": \"You only need to configure the version number, for example: https://api.openai.com/v1, the suffix will be automatically added.\",\n      \"modelDesc\": \"Some models support getting model list, if not supported please manually configure.\",\n      \"temperatureDesc\": \"Controls randomness of output. Lower values make generated content more deterministic.\",\n      \"topPDesc\": \"A nucleus sampling method, where the model considers the results of tokens with top_p probability mass. So 0.1 means only consider the top 10% probability mass. Usually we suggest to change this or temperature but not both.\",\n      \"customHeaders\": \"Custom Headers\",\n      \"customHeadersDesc\": \"Add custom HTTP headers with key-value pairs.\",\n      \"headerKey\": \"Key\",\n      \"headerValue\": \"Value\",\n      \"addHeader\": \"Add Header\",\n      \"connectionSuccess\": \"AI connection test passed\",\n      \"voice\": \"Voice Type\",\n      \"voiceDesc\": \"Specify the voice type for audio models, such as 'alloy', 'echo', 'fable', etc.\",\n      \"voicePlaceholder\": \"Enter voice type, e.g.: alloy\",\n      \"defaultModels\": {\n        \"title\": \"Default Free Models\",\n        \"desc\": \"NoteGen provides free AI model services for users, enabling basic functionality without configuration.\",\n        \"chatModel\": {\n          \"name\": \"Qwen/Qwen3-8B\",\n          \"type\": \"Chat Model\",\n          \"desc\": \"Suitable for daily conversations and text generation\"\n        },\n        \"embeddingModel\": {\n          \"name\": \"BAAI/bge-m3\",\n          \"type\": \"Embedding Model\",\n          \"desc\": \"Used for text vectorization and semantic search\"\n        },\n        \"visionModel\": {\n          \"name\": \"OpenGVLab/InternVL2-8B\",\n          \"type\": \"Vision Model\",\n          \"desc\": \"Supports image understanding and visual Q&A\"\n        },\n        \"completionModel\": {\n          \"name\": \"Fast Completion\",\n          \"type\": \"Completion Model\",\n          \"desc\": \"AI inline completion for Markdown editor, similar to GitHub Copilot, quickly generates continuation content\"\n        },\n        \"poweredBy\": \"Powered by SiliconFlow\"\n      },\n      \"connectionFailed\": \"Connection Failed\",\n      \"enableStream\": \"Stream Response\",\n      \"enableStreamDesc\": \"Enable streaming response to display generated content in real-time, but some models may not support this feature.\",\n      \"selectConfig\": \"Please select a configuration\",\n      \"models\": \"Model List\",\n      \"modelsDesc\": \"Manage all models under the current configuration here. Each model can have different types and parameters.\",\n      \"addModel\": \"Add Model\",\n      \"newModel\": \"New Model\",\n      \"checkConnection\": \"Test Connection\",\n      \"model\": \"Model\"\n    },\n    \"ocr\": {\n      \"title\": \"OCR\",\n      \"languagePacks\": \"Language Packs\",\n      \"checkModels\": \"Check all models here\",\n      \"modelInstruction\": \"Separate with commas, e.g.: eng,chi_sim\"\n    },\n    \"file\": {\n      \"title\": \"File Settings\",\n      \"desc\": \"Here, you can manage workspace settings and other file-related options.\",\n      \"workspace\": {\n        \"title\": \"Workspace Settings\",\n        \"desc\": \"Set the application's workspace directory where files will be saved\",\n        \"current\": \"Current Workspace Path\",\n        \"defaultPath\": \"Default Workspace\",\n        \"default\": \"Using default workspace path\",\n        \"custom\": \"Using custom workspace path\",\n        \"select\": \"Select Workspace Directory\",\n        \"reset\": \"Reset to Default Path\",\n        \"history\": \"History Paths\",\n        \"selectFromHistory\": \"Select workspace from history\",\n        \"clearHistory\": \"Clear History\",\n        \"actions\": \"Actions\",\n        \"searchPlaceholder\": \"Search workspace paths...\",\n        \"noResults\": \"No results found\"\n      },\n      \"info\": {\n        \"title\": \"Workspace Information\",\n        \"desc\": \"After changing the workspace, you need to restart the application for the changes to take full effect. Files in the new workspace will be displayed after restart.\"\n      },\n      \"toast\": {\n        \"updated\": \"Workspace Updated\",\n        \"updatedDesc\": \"Workspace set to: {path}\",\n        \"reset\": \"Workspace Reset\",\n        \"resetDesc\": \"Restored to default workspace\",\n        \"error\": \"Workspace Selection Failed\",\n        \"errorDesc\": \"Unable to select workspace directory, please try again\",\n        \"resetError\": \"Workspace Reset Failed\",\n        \"resetErrorDesc\": \"Unable to reset to default workspace, please try again\"\n      },\n      \"assets\": {\n        \"title\": \"Assets Path\",\n        \"desc\": \"Set the path where resources (e.g. images, videos, files etc.) used in writing will be saved. Resources will be saved at the same level as the currently edited markdown file.\",\n        \"select\": \"Set the path where resources used in writing will be saved\"\n      }\n    },\n    \"shortcuts\": {\n      \"title\": \"Shortcuts\",\n      \"desc\": \"Here, you can configure shortcuts to help you use NoteGen more efficiently.\",\n      \"resetDefaults\": \"Reset\",\n      \"clear\": \"Clear\",\n      \"noShortcut\": \"No Shortcut\",\n      \"shortcuts\": {\n        \"openWindow\": {\n          \"title\": \"Open/Hide Window\",\n          \"desc\": \"Open/Hide the main window.\"\n        },\n        \"quickRecordText\": {\n          \"title\": \"Quick Record Text\",\n          \"desc\": \"Quickly open the main window and switch to text recording.\"\n        }\n      }\n    }\n  },\n  \"record\": {\n    \"trash\": {\n      \"title\": \"Empty Trash\",\n      \"confirm\": \"Are you sure to empty the trash?\",\n      \"records\": \"{count} records can be restored\",\n      \"empty\": \"Empty\",\n      \"restoreAll\": \"Restore All\",\n      \"close\": \"Close Trash\"\n    },\n    \"queue\": {\n      \"ocr\": \"OCR recognition\",\n      \"ai\": \"AI content recognition\",\n      \"upload\": \"Upload to image host\",\n      \"jsdelivr\": \"Notify jsdelivr cache\",\n      \"save\": \"Save\",\n      \"recording\": \"Recording...\",\n      \"recorded\": \"Recorded\",\n      \"record\": \"Record\",\n      \"detected\": \"Detected\"\n    },\n    \"mark\": {\n      \"empty\": \"No records yet\",\n      \"loading\": \"Loading...\",\n      \"createdAt\": \"Created At\",\n      \"type\": {\n        \"scan\": \"Scan\",\n        \"image\": \"Image\",\n        \"screenshot\": \"Screenshot\",\n        \"text\": \"Text\",\n        \"recording\": \"Recording\",\n        \"file\": \"File\",\n        \"link\": \"Link\",\n        \"todo\": \"Todo\",\n        \"pdf\": \"PDF\",\n        \"upload\": \"Upload Record\",\n        \"download\": \"Download Record\",\n        \"uploadTo\": \"Sync from local to {provider}\",\n        \"downloadFrom\": \"Sync from {provider} to local\"\n      },\n      \"uploadSuccess\": \"Record upload success\",\n      \"downloadSuccess\": \"Record download success\",\n      \"desc\": \"Description\",\n      \"content\": \"Content\",\n      \"progress\": {\n        \"cacheImage\": \"Caching image\",\n        \"ocr\": \"OCR recognition\",\n        \"aiAnalysis\": \"AI content analysis\",\n        \"uploadImage\": \"Uploading to image host\",\n        \"jsdelivrCache\": \"Notifying jsdelivr cache\",\n        \"cacheFile\": \"Caching file\",\n        \"cacheScreenshot\": \"Caching screenshot\",\n        \"textAnalysis\": \"Text analysis\",\n        \"save\": \"Saving\",\n        \"saveImage\": \"Saving image\",\n        \"newToken\": \"Create access token\",\n        \"newTokenDesc\": \"New token must be configured with repo permission, configuration will automatically create file repository (private) and image repository.\"\n      },\n      \"imageGallery\": {\n        \"expand\": \"Expand\",\n        \"collapse\": \"Collapse\"\n      },\n      \"text\": {\n        \"title\": \"Record Text\",\n        \"description\": \"Record a piece of text, which will be inserted into the appropriate position when organizing notes.\",\n        \"characterCount\": \"{count} characters\",\n        \"save\": \"Save\",\n        \"autoReadClipboard\": \"Auto-read clipboard text\"\n      },\n      \"link\": {\n        \"title\": \"Record Link\",\n        \"description\": \"Enter a webpage link, and the system will automatically crawl the page content and save it\",\n        \"save\": \"Save\",\n        \"autoReadClipboard\": \"Auto-read clipboard link\"\n      },\n      \"todo\": {\n        \"title\": \"Todo Record\",\n        \"description\": \"Create todo items to manage your tasks\",\n        \"titlePlaceholder\": \"Enter todo title...\",\n        \"descriptionPlaceholder\": \"Enter detailed description (optional)\",\n        \"priority\": \"Priority\",\n        \"priorityLow\": \"Low\",\n        \"priorityMedium\": \"Medium\",\n        \"priorityHigh\": \"High\",\n        \"dateRange\": \"Date Range\",\n        \"dateRangePlaceholder\": \"Select date range\",\n        \"dueDate\": \"Due Date\",\n        \"dueDatePlaceholder\": \"Select date\",\n        \"save\": \"Create Todo\",\n        \"saveEdit\": \"Save\",\n        \"edit\": \"Edit Todo\",\n        \"editDescription\": \"Modify the details of the todo item\",\n        \"cancel\": \"Cancel\",\n        \"selectTag\": \"Select Tag\",\n        \"completed\": \"Completed\",\n        \"uncompleted\": \"Uncompleted\"\n      },\n      \"clipboard\": {\n        \"detectedImage\": \"Clipboard image detected\",\n        \"detectedText\": \"Clipboard text detected\"\n      },\n      \"tag\": {\n        \"searchPlaceholder\": \"Create or search tags...\",\n        \"noResults\": \"No matching tags found\",\n        \"quickAdd\": \"Quick Create\",\n        \"pinned\": \"Pinned\",\n        \"others\": \"Others\",\n        \"rename\": \"Rename\",\n        \"delete\": \"Delete\",\n        \"pin\": \"Pin\",\n        \"unpin\": \"Unpin\",\n        \"newTag\": \"New Tag\",\n        \"newTagPlaceholder\": \"Enter tag name...\",\n        \"add\": \"Add\"\n      },\n      \"mark\": {\n        \"empty\": \"No records\",\n        \"emptyHint\": \"Use the toolbar at the top to create your first record!\",\n        \"type\": {\n          \"text\": \"Text\"\n        },\n        \"chat\": {\n          \"modeSelect\": {\n            \"chat\": \"Chat\",\n            \"agent\": \"Agent\"\n          },\n          \"agent\": {\n            \"running\": \"Agent Running\",\n            \"thinking\": \"Thinking\",\n            \"acting\": \"Acting\",\n            \"observation\": \"Observation\",\n            \"thought\": \"Thought\",\n            \"action\": \"Action\",\n            \"toolCalls\": \"Tool Calls\",\n            \"confirmation\": {\n              \"title\": \"Confirm Action\",\n              \"description\": \"The agent wants to perform the following action. Please confirm to continue.\",\n              \"tool\": \"Tool\",\n              \"parameters\": \"Parameters\",\n              \"cancel\": \"Cancel\",\n              \"confirm\": \"Confirm\",\n              \"confirmed\": \"Confirmed\",\n              \"cancelled\": \"Cancelled\"\n            }\n          },\n          \"placeholder\": {\n            \"default\": \"Ask questions or organize your notes into an article...\",\n            \"noApiKey\": \"API Key not configured, AI chat feature is unavailable...\",\n            \"on\": \"AI Suggestion On\",\n            \"off\": \"AI Suggestion Off\"\n          },\n          \"header\": {\n            \"configApiKey\": \"Configure API KEY\",\n            \"clearChat\": \"Clear Chat\",\n            \"configPrompt\": \"Configure Prompt\",\n            \"selectPrompt\": \"Select Prompt\"\n          },\n          \"clipboard\": {\n            \"image\": {\n              \"detected\": \"Image detected in clipboard:\",\n              \"recording\": \"Recording...\",\n              \"recorded\": \"Recorded\",\n              \"record\": \"Record\"\n            },\n            \"text\": {\n              \"detected\": \"Text detected in clipboard:\",\n              \"recorded\": \"Recorded\",\n              \"record\": \"Record\"\n            }\n          },\n          \"messageControl\": {\n            \"words\": \"words\",\n            \"summary\": \"Summary\"\n          },\n          \"mcp\": {\n            \"maxIterationsReached\": \"Maximum tool call iterations reached\",\n            \"toolCall\": \"MCP Server\",\n            \"params\": \"Parameters\",\n            \"result\": \"Result\",\n            \"copy\": \"Copy\",\n            \"paramsCopied\": \"Parameters copied\",\n            \"resultCopied\": \"Result copied\",\n            \"calling\": \"Calling\",\n            \"success\": \"Completed\",\n            \"error\": \"Failed\"\n          },\n          \"empty\": {\n            \"title\": \"Start AI Conversation\",\n            \"subtitle\": \"Use Chat or Agent mode to interact with AI\",\n            \"currentModel\": \"Current Model\",\n            \"currentPrompt\": \"Current Prompt\",\n            \"currentMode\": \"Conversation Mode\",\n            \"noModel\": \"No model set\",\n            \"noPrompt\": \"No prompt set\",\n            \"configureModel\": \"Configure Model\",\n            \"recentConversations\": \"Recent Conversations\",\n            \"deleteConversation\": \"Delete conversation\",\n            \"conversationHistory\": \"History\",\n            \"viewMore\": \"View more\",\n            \"messages\": \"messages\",\n            \"searchPlaceholder\": \"Search conversations...\",\n            \"noMatchingConversations\": \"No matching conversations found\",\n            \"noConversationHistory\": \"No conversation history\",\n            \"quickPrompts\": {\n              \"title\": \"Quick Start\",\n              \"writeNote\": \"Help me write a note\",\n              \"summarize\": \"Help me summarize this content\",\n              \"brainstorm\": \"Help me brainstorm ideas\",\n              \"explain\": \"Help me explain this concept\"\n            },\n            \"modeHint\": \"Click the\",\n            \"modeHintSuffix\": \"button on the left side of the input box to switch conversation mode\"\n          },\n          \"content\": {\n            \"organize\": \"Organize your records into an article:\"\n          },\n          \"note\": {\n            \"writing\": \"Write\",\n            \"convert\": \"Convert Article\",\n            \"description\": \"The current note is generated by AI and cannot be edited. Convert the current note to an article (generate a local file) for secondary creation in the writing page.\",\n            \"filename\": \"Filename\",\n            \"selectFolder\": \"Select folder\",\n            \"rootDirectory\": \"Root directory\",\n            \"deleteTag\": \"Delete current tag, records and notes (can be restored from trash)\",\n            \"warning\": \"After conversion, you will be redirected to the writing page.\",\n            \"convert_button\": \"Convert\"\n          },\n          \"mark\": {\n            \"recorded\": \"Recorded\",\n            \"record\": \"Record\"\n          },\n          \"send\": \"Send\"\n        },\n        \"text\": {\n          \"title\": \"Record Text\",\n          \"description\": \"Record a piece of text, which will be inserted into the appropriate position when organizing notes.\",\n          \"characterCount\": \"{count} characters\",\n          \"save\": \"Save\"\n        },\n        \"clipboard\": {\n          \"detectedImage\": \"Clipboard image detected\",\n          \"detectedText\": \"Clipboard text detected\"\n        },\n        \"tag\": {\n          \"searchPlaceholder\": \"Create or search tags...\",\n          \"noResults\": \"No matching tags found\",\n          \"quickAdd\": \"Quick Create\",\n          \"pinned\": \"Pinned\",\n          \"others\": \"Others\",\n          \"rename\": \"Rename\",\n          \"delete\": \"Delete\",\n          \"pin\": \"Pin\",\n          \"unpin\": \"Unpin\"\n        },\n        \"progress\": {\n          \"cacheImage\": \"Caching image\",\n          \"ocr\": \"OCR recognition\",\n          \"aiAnalysis\": \"AI content analysis\",\n          \"uploadImage\": \"Uploading to image host\",\n          \"jsdelivrCache\": \"Notifying jsdelivr cache\",\n          \"cacheFile\": \"Caching file\",\n          \"cacheScreenshot\": \"Caching screenshot\",\n          \"textAnalysis\": \"Text analysis\",\n          \"save\": \"Saving\",\n          \"saveImage\": \"Saving image\"\n        }\n      },\n      \"toolbar\": {\n        \"search\": \"Search\",\n        \"filter\": {\n          \"title\": \"Filter\",\n          \"description\": \"Instantly narrow records by content, time, and type.\",\n          \"search\": \"Search\",\n          \"searchPlaceholder\": \"Search record text, description, or link\",\n          \"type\": \"Type\",\n          \"time\": \"Time\",\n          \"tag\": \"Tag\",\n          \"allTags\": \"All tags\",\n          \"clear\": \"Clear filters\",\n          \"selectAllTypes\": \"Select all types\",\n          \"clearTypes\": \"Clear type selection\",\n          \"timeOptions\": {\n            \"all\": \"All time\",\n            \"today\": \"Today\",\n            \"last7Days\": \"Last 7 days\",\n            \"last30Days\": \"Last 30 days\"\n          }\n        },\n        \"trash\": \"Trash\",\n        \"restore\": \"Restore\",\n        \"delete\": \"Delete\",\n        \"deleteConfirm\": \"Are you sure to delete?\",\n        \"moveTag\": \"Move Tag\",\n        \"convertTo\": \"Convert to {type}\",\n        \"copyLink\": \"Copy Link\",\n        \"copied\": \"Copied to clipboard!\",\n        \"regenerateDesc\": \"Regenerate Description\",\n        \"viewFolder\": \"View Folder\",\n        \"viewFile\": \"View Original File\",\n        \"deleteForever\": \"Delete Forever\",\n        \"sortByName\": \"Sort by Name\",\n        \"sortByCreated\": \"Sort by Created\",\n        \"sortByModified\": \"Sort by Modified\",\n        \"sortAsc\": \"Sort Ascending\",\n        \"sortDesc\": \"Sort Descending\",\n        \"sort\": \"Sort\",\n        \"processingVectors\": \"Processing Vector Data\",\n        \"calculateVectors\": \"Calculate Document Vectors\",\n        \"multiSelect\": \"Multi Select\",\n        \"exitMultiSelect\": \"Exit Multi Select\",\n        \"organizeNotes\": \"Organize Notes\",\n        \"organizeSuccess\": \"Notes organized successfully: {title}\",\n        \"organizeError\": \"Failed to organize notes\",\n        \"currentTag\": \"Current Tag\",\n        \"text\": \"Record Text\",\n        \"recording\": \"Recording Record\",\n        \"scan\": \"Scan Image\",\n        \"image\": \"Upload Image\",\n        \"link\": \"Record Link\",\n        \"file\": \"Upload File\",\n        \"todo\": \"Todo Record\",\n        \"closeTrash\": \"Close Trash\",\n        \"selectAll\": \"Select All\",\n        \"deselectAll\": \"Deselect All\",\n        \"selectedCount\": \"{count} items selected\",\n        \"visibleCount\": \"{count} records\",\n        \"moveSelectedTags\": \"Move selected {count} items\",\n        \"deleteSelected\": \"Delete selected {count} items\",\n        \"deleteSelectedForever\": \"Permanently delete selected {count} items\",\n        \"view\": {\n          \"list\": \"List view\",\n          \"compact\": \"Compact view\",\n          \"cards\": \"Card view\"\n        }\n      },\n      \"list\": {\n        \"title\": \"Records\",\n        \"emptyFiltered\": \"No matching records\",\n        \"emptyFilteredHint\": \"Try adjusting the search or filters\",\n        \"filteredLabel\": \"{count} filtered\",\n        \"filtered\": \"Filtered\",\n        \"filteredByTag\": \"tag\",\n        \"filteredByType\": \"{count} types\",\n        \"filteredSummary\": \"Showing {count} results · {filters}\",\n        \"searchChip\": \"Search: {value}\",\n        \"time\": {\n          \"today\": \"Today\",\n          \"last7Days\": \"Last 7 days\",\n          \"last30Days\": \"Last 30 days\"\n        }\n      },\n      \"note\": {\n        \"organizeAs\": \"Organize as\",\n        \"template\": \"Template\",\n        \"setting\": \"Settings\",\n        \"confirm\": \"确认\",\n        \"cancel\": \"取消\",\n        \"removeThinking\": \"移除思考过程\",\n        \"stop\": \"Stop\"\n      }\n    },\n    \"chat\": {\n      \"condensing\": \"Condensing context...\",\n      \"condensed\": {\n        \"message\": \"Condensed {count} historical messages\"\n      },\n      \"empty\": {\n        \"features\": [\n          {\n            \"chat\": \"Chat with AI bot\"\n          },\n          {\n            \"linked\": \"Linked with your records or notes\"\n          },\n          {\n            \"clipboard\": \"Recognize clipboard records or images\"\n          },\n          {\n            \"organize\": \"Organize your records into notes\"\n          }\n        ],\n        \"title\": \"Start chatting with AI\",\n        \"subtitle\": \"Interact with AI using Chat or Agent mode\",\n        \"currentModel\": \"Current Model\",\n        \"currentPrompt\": \"Current Prompt\",\n        \"currentMode\": \"Conversation Mode\",\n        \"noModel\": \"No model set\",\n        \"noPrompt\": \"No prompt set\",\n        \"configureModel\": \"Configure Model\",\n        \"recentConversations\": \"Recent Conversations\",\n        \"deleteConversation\": \"Delete Conversation\",\n        \"conversationHistory\": \"History\",\n        \"viewMore\": \"View More\",\n        \"messages\": \"messages\",\n        \"searchPlaceholder\": \"Search conversations...\",\n        \"noMatchingConversations\": \"No matching conversations found\",\n        \"noConversationHistory\": \"No conversation history yet\",\n        \"quickPrompts\": {\n          \"title\": \"快速开始\",\n          \"writeNote\": \"帮我写一篇笔记\",\n          \"summarize\": \"帮我总结这段内容\",\n          \"brainstorm\": \"帮我头脑风暴一些想法\",\n          \"explain\": \"帮我解释这个概念\"\n        }\n      },\n      \"newChat\": \"New Chat with New Tag\",\n      \"removeChat\": \"Remove Chat with Current Tag\",\n      \"confirmNew\": \"Create New Tag\",\n      \"confirmNewDescription\": \"Are you sure you want to create a new tag to start a conversation?\",\n      \"confirmRemove\": \"Delete Tag\",\n      \"confirmRemoveDescription\": \"Please note that deleting this tag will also delete all records within it. Please confirm again.\",\n      \"content\": {\n        \"organize\": \"Organize your records into an article:\"\n      },\n      \"quote\": {\n        \"lineSingle\": \"Quoted from {fileName} line {line}\",\n        \"lineRange\": \"Quoted from {fileName} lines {startLine}-{endLine}\",\n        \"noLine\": \"Quoted from {fileName}\"\n      },\n      \"note\": {\n        \"organize\": \"Organize\",\n        \"writing\": \"Write\",\n        \"convert\": \"Convert Article\",\n        \"description\": \"The current note is generated by AI and cannot be edited. Convert the current note to an article (generate a local file) for secondary creation in the writing page.\",\n        \"filename\": \"Filename\",\n        \"selectFolder\": \"Select folder\",\n        \"rootDirectory\": \"Root directory\",\n        \"deleteTag\": \"Delete current tag, records and notes (can be restored from trash)\",\n        \"warning\": \"After conversion, you will be redirected to the writing page.\",\n        \"convert_button\": \"Convert\",\n        \"organizeAs\": \"Organize your records into an article:\",\n        \"templateContent\": \"Template content\",\n        \"recordRange\": \"Record range\",\n        \"filterThinkingContent\": \"Remove thinking content from records\",\n        \"startOrganize\": \"Start organizing\",\n        \"manageTemplate\": \"Manage template\",\n        \"cancel\": \"Cancel\",\n        \"stop\": \"Stop\"\n      },\n      \"mark\": {\n        \"recorded\": \"Recorded\",\n        \"record\": \"Record\"\n      },\n      \"input\": {\n        \"organize\": \"Organize\",\n        \"chat\": \"Chat\",\n        \"placeholder\": {\n          \"default\": \"Type a message...\",\n          \"noApiKey\": \"No API Key configured, can't use AI chat...\",\n          \"on\": \"AI suggestions on\",\n          \"off\": \"AI suggestions off\",\n          \"noPrimaryModel\": \"No primary model configured, can't use AI chat...\"\n        },\n        \"translate\": {\n          \"tooltip\": \"Translate\",\n          \"translating\": \"Translating...\",\n          \"showOriginal\": \"Show Original\",\n          \"alreadyTranslated\": \"Translated to\"\n        },\n        \"clipboardMonitor\": {\n          \"enable\": \"Clipboard monitoring (on)\",\n          \"disable\": \"Clipboard monitoring (off)\"\n        },\n        \"send\": \"Send\",\n        \"stop\": \"Stop\",\n        \"stopped\": \"Conversation stopped\",\n        \"terminate\": \"Terminate\",\n        \"tagLink\": {\n          \"on\": \"Linked to tag\",\n          \"off\": \"Not linked to tag\"\n        },\n        \"modelSelect\": {\n          \"tooltip\": \"Select AI model\",\n          \"placeholder\": \"Search AI models\",\n          \"noModel\": \"No model found\"\n        },\n        \"promptSelect\": {\n          \"tooltip\": \"Select prompt\",\n          \"placeholder\": \"Search prompts\"\n        },\n        \"newChat\": \"New Chat\",\n        \"mcp\": {\n          \"tooltip\": \"MCP server\"\n        },\n        \"chatLanguage\": {\n          \"tooltip\": \"Select chat language\",\n          \"placeholder\": \"Search language\"\n        },\n        \"rag\": {\n          \"notSupported\": \"Vector model is not supported\",\n          \"enabled\": \"Knowledge Base Search (Enabled)\",\n          \"disabled\": \"Knowledge Base Search (Disabled)\"\n        },\n        \"modeSelect\": {\n          \"tooltip\": \"Select input mode\",\n          \"chat\": \"Chat mode\",\n          \"gen\": \"Organize mode\",\n          \"translate\": \"Translate mode\"\n        },\n        \"chatModeSelect\": {\n          \"chatDescription\": \"Quick conversation, analysis-first\",\n          \"agentDescription\": \"Smart assistant, can execute actions\"\n        },\n        \"attachImage\": \"Attach images\",\n        \"imageSelector\": {\n          \"title\": \"Select Images\",\n          \"local\": \"Local Files\",\n          \"records\": \"From Records\",\n          \"selectFiles\": \"Select Local Images\",\n          \"noRecords\": \"No image records available\",\n          \"cancel\": \"Cancel\",\n          \"confirm\": \"Confirm\"\n        },\n        \"agent\": {\n          \"running\": \"Agent Running\",\n          \"thinking\": \"Thinking\",\n          \"analyzingRequest\": \"Agent is analyzing your request...\",\n          \"acting\": \"Acting\",\n          \"observation\": \"Observation\",\n          \"thought\": \"Thought\",\n          \"action\": \"Action\",\n          \"toolCalls\": \"Tool Calls\",\n          \"autoFinal\": {\n            \"createNote\": \"Created note \\\"{name}\\\".\",\n            \"createFile\": \"Created file \\\"{name}\\\".\"\n          },\n          \"confirmation\": {\n            \"title\": \"Confirm Action\",\n            \"description\": \"The agent wants to perform the following action. Please confirm to continue.\",\n            \"tool\": \"Tool\",\n            \"parameters\": \"Parameters\",\n            \"cancel\": \"Cancel\",\n            \"confirm\": \"Confirm\",\n            \"confirmed\": \"Confirmed\",\n            \"cancelled\": \"Cancelled\",\n            \"fallback\": {\n              \"title\": \"Review action\",\n              \"description\": \"Please confirm the target and content of this action.\"\n            },\n            \"params\": {\n              \"filePath\": \"File path\",\n              \"content\": \"File content\",\n              \"sourcePath\": \"Source path\",\n              \"targetPath\": \"Target path\",\n              \"files\": \"Files\",\n              \"newName\": \"New name\",\n              \"scriptName\": \"Script name\",\n              \"command\": \"Command\"\n            },\n            \"tools\": {\n              \"create_file\": {\n                \"title\": \"Create file\",\n                \"description\": \"A new file will be created in the workspace.\"\n              },\n              \"create_files_batch\": {\n                \"title\": \"Create files\",\n                \"description\": \"Multiple new files will be created in the workspace.\"\n              },\n              \"rename_file\": {\n                \"title\": \"Rename file\",\n                \"description\": \"The selected file will be renamed.\"\n              },\n              \"move_file\": {\n                \"title\": \"Move file\",\n                \"description\": \"The file will be moved to a new location.\"\n              },\n              \"copy_file\": {\n                \"title\": \"Copy file\",\n                \"description\": \"A copy of the file will be created at the target location.\"\n              },\n              \"replace_editor_content\": {\n                \"title\": \"Replace editor content\",\n                \"description\": \"The current editor content will be replaced.\"\n              },\n              \"insert_at_cursor\": {\n                \"title\": \"Insert at cursor\",\n                \"description\": \"Content will be inserted at the current cursor position.\"\n              },\n              \"delete_markdown_file\": {\n                \"title\": \"Delete file\",\n                \"description\": \"The selected file will be permanently deleted.\"\n              },\n              \"execute_skill_script\": {\n                \"title\": \"Run script\",\n                \"description\": \"A skill-provided script or command will be executed.\"\n              }\n            }\n          }\n        },\n        \"fileLink\": {\n          \"tooltip\": \"Link File\",\n          \"selectFile\": \"Select File\",\n          \"linkedFile\": \"Linked File\",\n          \"searchPlaceholder\": \"Search files...\",\n          \"noFiles\": \"No files found\",\n          \"loading\": \"Loading...\"\n        }\n      },\n      \"header\": {\n        \"configApiKey\": \"Configure API KEY\",\n        \"clearChat\": \"Clear Chat\",\n        \"selectPrompt\": \"Select Prompt\",\n        \"noModel\": \"AI model not selected\",\n        \"configPrompt\": \"Configure Prompt\"\n      },\n      \"clipboard\": {\n        \"image\": {\n          \"detected\": \"Image detected in clipboard:\",\n          \"recording\": \"Recording...\",\n          \"recorded\": \"Recorded\",\n          \"record\": \"Record\"\n        },\n        \"text\": {\n          \"detected\": \"Text detected in clipboard:\",\n          \"recorded\": \"Recorded\",\n          \"record\": \"Record\"\n        }\n      },\n      \"messageControl\": {\n        \"words\": \"words\",\n        \"summary\": \"Summary\",\n        \"readAloud\": \"Read Aloud\",\n        \"playing\": \"Playing\",\n        \"loading\": \"Loading\",\n        \"stop\": \"Stop Playing\",\n        \"copy\": \"Copy\",\n        \"copied\": \"Copied\"\n      },\n      \"ragSources\": {\n        \"label\": \"Found {count} notes in knowledge base\",\n        \"openFile\": \"Open file\"\n      },\n      \"preview\": {\n        \"close\": \"Close\",\n        \"copy\": \"Copy\",\n        \"copied\": \"Copied!\"\n      },\n      \"control\": {\n        \"edit\": \"Edit\",\n        \"save\": \"Save\",\n        \"cancel\": \"Cancel\",\n        \"delete\": \"Delete\",\n        \"deleteConfirm\": \"Are you sure to delete this message?\"\n      }\n    },\n    \"tag\": {\n      \"add\": \"Add Tag\",\n      \"edit\": \"Edit Tag\",\n      \"delete\": \"Delete Tag\",\n      \"deleteConfirm\": \"Are you sure to delete this tag?\",\n      \"placeholder\": \"Enter tag name\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"Search notes and articles...\",\n    \"results\": \"{count} search results\",\n    \"noResults\": \"No search results\",\n    \"tryDifferentKeywords\": \"Try using different keywords\",\n    \"mode\": {\n      \"fuzzy\": \"Fuzzy\",\n      \"exact\": \"Exact\"\n    },\n    \"item\": {\n      \"record\": \"Record\",\n      \"article\": \"Article\",\n      \"matches\": \"{count} matches\",\n      \"scanType\": \"scan\"\n    }\n  },\n  \"image\": {\n    \"root\": \"Image Repository\",\n    \"noData\": {\n      \"title\": \"Sync feature not enabled\",\n      \"desc\": \"Please go to the system settings page to configure Github sync.\",\n      \"goToSettings\": \"Go to Settings\",\n      \"howToUse\": \"How to use sync feature?\"\n    }\n  },\n  \"navigation\": {\n    \"chat\": \"Chat\",\n    \"record\": \"Record\",\n    \"quickRecord\": \"Quick Record\",\n    \"write\": \"Write\",\n    \"search\": \"Search\",\n    \"githubImageHosting\": \"Github Image Hosting\",\n    \"login\": \"Login\",\n    \"loading\": \"Loading\",\n    \"view\": \"View\",\n    \"logout\": \"Logout\",\n    \"setting\": \"Settings\",\n    \"activity\": \"Activity\",\n    \"files\": \"Notes\",\n    \"outline\": \"Outline\",\n    \"showLeftSidebar\": \"Show Left Sidebar\",\n    \"hideLeftSidebar\": \"Hide Left Sidebar\",\n    \"showCenterPanel\": \"Show Editor\",\n    \"hideCenterPanel\": \"Hide Editor\",\n    \"showRightSidebar\": \"Show Right Sidebar\",\n    \"hideRightSidebar\": \"Hide Right Sidebar\",\n    \"searchPlaceholder\": \"Search notes or records...\"\n  },\n  \"activity\": {\n    \"title\": \"Activity Calendar\",\n    \"description\": \"Review your daily records, chats, and writing activity in one place. This first version is derived from existing records, user chats, and note modification times.\",\n    \"drawer\": {\n      \"title\": \"Activity\",\n      \"description\": \"Quickly review today's status and your recent activity trend.\",\n      \"today\": \"Today\"\n    },\n    \"loading\": \"Loading activity data...\",\n    \"empty\": \"No activity data yet\",\n    \"refresh\": \"Refresh\",\n    \"summary\": {\n      \"totalCount\": \"Total Activity\",\n      \"activeDays\": \"Active Days\",\n      \"records\": \"Records\",\n      \"chats\": \"Chats\",\n      \"writing\": \"Writing\"\n    },\n    \"labels\": {\n      \"record\": \"Record\",\n      \"writing\": \"Writing\",\n      \"chat\": \"Chat\"\n    },\n    \"heatmap\": {\n      \"title\": \"Last 26 Weeks\",\n      \"range\": \"{startDate} - {endDate}\",\n      \"less\": \"Less\",\n      \"more\": \"More\",\n      \"dayCount\": \"activities\",\n      \"emptyDay\": \"No activity\"\n    },\n    \"detail\": {\n      \"title\": \"Day Details\",\n      \"empty\": \"Select a day to inspect its activity details.\"\n    }\n  },\n  \"marks\": {\n    \"types\": {\n      \"screenshot\": \"Screenshot\",\n      \"text\": \"Text\",\n      \"image\": \"Image\"\n    }\n  },\n  \"tags\": {\n    \"inspiration\": \"Inspiration\"\n  },\n  \"sync\": {\n    \"status\": \"Sync Repository Status\",\n    \"imageRepo\": \"Image Repository\",\n    \"articleRepo\": \"Article Repository\"\n  },\n  \"ai\": {\n    \"thinking\": \"Thinking\",\n    \"error\": {\n      \"title\": \"AI Error\",\n      \"noAddress\": \"Please set AI address first\"\n    }\n  },\n  \"article\": {\n    \"sync\": {\n      \"syncingRemote\": \"Pulling remote file...\",\n      \"syncComplete\": \"Sync Complete\",\n      \"pullingRemote\": \"Fetching latest content from remote server...\"\n    },\n    \"syncConfirm\": {\n      \"title\": \"Remote File Update Detected\",\n      \"description\": \"File {fileName} has remote updates\",\n      \"commitInfo\": \"Latest Commit Info\",\n      \"commitMessage\": \"Commit Message\",\n      \"author\": \"Author\",\n      \"changes\": \"Changes\",\n      \"confirmMessage\": \"Are you sure you want to pull the remote version and overwrite the local file? This action cannot be undone.\",\n      \"cancel\": \"Cancel\",\n      \"confirmPull\": \"Confirm Pull\"\n    },\n    \"emptyState\": {\n      \"title\": \"Start Creating\",\n      \"subtitle\": \"Select a file to start editing, or create a new note\",\n      \"tip\": \"💡 Tip: You can also select files from the left sidebar\",\n      \"actions\": {\n        \"newNote\": {\n          \"title\": \"Create Note\",\n          \"desc\": \"Create a new Markdown note\"\n        },\n        \"newRecord\": {\n          \"title\": \"Create Record\",\n          \"desc\": \"Open text recording feature\"\n        },\n        \"globalSearch\": {\n          \"title\": \"Global Search\",\n          \"desc\": \"Quickly find your note content\"\n        },\n        \"openWorkspace\": {\n          \"title\": \"Open Workspace\",\n          \"desc\": \"Select or switch workspace directory\"\n        }\n      },\n      \"onboarding\": {\n        \"title\": \"Onboarding\",\n        \"subtitle\": \"Walk through these three tasks to learn the core NoteGen flow.\",\n        \"dismiss\": \"Skip onboarding\",\n        \"reopen\": \"Show onboarding again\",\n        \"start\": \"Start\",\n        \"viewHint\": \"Show hint\",\n        \"continue\": \"Continue\",\n        \"completed\": \"Done\",\n        \"allDone\": \"All getting-started tasks are complete. You've already tried the core NoteGen workflow.\",\n        \"stepLabel\": \"Task ({current}/{total})\",\n        \"stepCompletedLabel\": \"Completed Task ({current}/{total})\",\n        \"afterOrganizeDialog\": {\n          \"title\": \"Completed Task (2/3)\",\n          \"description\": \"You've turned the record into a note. Do you want to continue and use the AI Agent to turn this note into a bilingual version?\",\n          \"confirm\": \"Continue\",\n          \"cancel\": \"Not now\"\n        },\n        \"agentPrompt\": {\n          \"label\": \"Sample Prompt\",\n          \"use\": \"Use This Prompt\",\n          \"intro\": \"Please directly revise the note I just organized into a bilingual Chinese-English version.\",\n          \"requirement1\": \"\",\n          \"requirement2\": \"\",\n          \"requirement3\": \"\",\n          \"requirement4\": \"\",\n          \"outro\": \"\"\n        },\n        \"steps\": {\n          \"createRecord\": {\n            \"title\": \"Create your first record\",\n            \"desc\": \"Save a sample record and learn where quick capture lives.\"\n          },\n          \"organizeNote\": {\n            \"title\": \"Organize it into a note\",\n            \"desc\": \"Turn that record into a structured note.\"\n          },\n          \"aiPolish\": {\n            \"title\": \"Use Agent for bilingual translation\",\n            \"desc\": \"Use the AI Agent to turn the note you just organized into a bilingual version.\"\n          }\n        },\n        \"completedStates\": {\n          \"create-record\": {\n            \"title\": \"Your first record is saved\",\n            \"desc\": \"You now know where quick capture lives.\"\n          },\n          \"organize-note\": {\n            \"title\": \"Your record is now a note\",\n            \"desc\": \"Next, try using AI to revise the note.\"\n          },\n          \"ai-polish\": {\n            \"title\": \"You used the Agent on the note\",\n            \"desc\": \"You've completed the flow from capture to note organization to Agent-assisted processing.\"\n          }\n        },\n        \"spotlight\": {\n          \"create-record\": {\n            \"title\": \"This is the quick record entry\",\n            \"desc\": \"Click here to open text capture. We'll preload a sample record so you can save it right away.\"\n          },\n          \"organize-note\": {\n            \"title\": \"This button organizes records into a note\",\n            \"desc\": \"Use it to turn your captured record into a full Markdown note.\"\n          },\n          \"ai-polish\": {\n            \"title\": \"Use the Agent on the note you just created here\",\n            \"desc\": \"Insert the sample prompt into chat and send it. The Agent will generate a bilingual version based on the current note.\"\n          }\n        }\n      }\n    },\n    \"unsupportedFile\": {\n      \"title\": \"Cannot Preview This File\",\n      \"fileName\": \"File Name\",\n      \"filePath\": \"File Path\",\n      \"fileSize\": \"File Size\",\n      \"modifiedTime\": \"Modified Time\",\n      \"createdTime\": \"Created Time\",\n      \"pathCopied\": \"Path copied\",\n      \"openExternal\": \"Open with External App\",\n      \"openDirectory\": \"Open File Directory\"\n    },\n    \"file\": {\n      \"toolbar\": {\n        \"accessRepo\": \"Access Repository\",\n        \"loadingSync\": \"Loading sync info\",\n        \"configSync\": \"Configure Sync\",\n        \"newArticle\": \"New Article\",\n        \"newFolder\": \"New Folder\",\n        \"refresh\": \"Refresh\",\n        \"toggleFolders\": \"Toggle Folders\",\n        \"expandAll\": \"Expand All\",\n        \"collapseAll\": \"Collapse All\",\n        \"sortByName\": \"Sort by Name\",\n        \"sortByCreated\": \"Sort by Created\",\n        \"sortByModified\": \"Sort by Modified\",\n        \"sortAsc\": \"Sort Ascending\",\n        \"sortDesc\": \"Sort Descending\",\n        \"sort\": \"Sort\",\n        \"hideCloudFiles\": \"Hide Cloud Files\",\n        \"showCloudFiles\": \"Show Cloud Files\",\n        \"processingVectors\": \"Processing Vector Data\",\n        \"calculateVectors\": \"Knowledge Base Calculation (Full)\",\n        \"importMarkdown\": \"Import\",\n        \"importing\": \"Importing...\",\n        \"importSuccess\": \"Import Successful\",\n        \"importSuccessDesc\": \"Successfully imported {count} files\",\n        \"importError\": \"Import Failed\"\n      },\n      \"sync\": {\n        \"syncingRemote\": \"Pulling remote file...\",\n        \"syncComplete\": \"Sync Complete\",\n        \"pullingRemote\": \"Fetching latest content from remote server...\",\n        \"pullComplete\": \"Pull Complete\"\n      },\n      \"context\": {\n        \"viewDirectory\": \"View Directory\",\n        \"cut\": \"Cut\",\n        \"copy\": \"Copy\",\n        \"paste\": \"Paste\",\n        \"rename\": \"Rename\",\n        \"deleteSyncFile\": \"Delete Sync File\",\n        \"deleteLocalFile\": \"Delete Local File\",\n        \"delete\": \"Delete\",\n        \"confirmDelete\": \"Are you sure you want to delete the folder \\\"{name}\\\"? This will delete the folder and all its contents.\",\n        \"deleteSuccess\": \"Deleted successfully\",\n        \"deleteFailed\": \"Delete failed\",\n        \"newFile\": \"New File\",\n        \"newFolder\": \"New Folder\",\n        \"syncFolder\": \"Sync Folder\",\n        \"syncFolderDesc\": \"Sync all Markdown files in the current folder\",\n        \"syncFolderSuccess\": \"Sync folder success\",\n        \"syncFolderError\": \"Sync folder error\",\n        \"syncFolderProgress\": \"Syncing folder...\",\n        \"deleteSyncFileSuccess\": \"Delete Sync File Success\",\n        \"deleteSyncFileError\": \"Delete Sync File Error\",\n        \"knowledgeBase\": \"Knowledge Base\",\n        \"calculateVectors\": \"Calculate Vectors\",\n        \"updateVectors\": \"Update Vectors\",\n        \"deleteVectors\": \"Delete Vectors\",\n        \"includeInKB\": \"Include in Knowledge Base\",\n        \"includeInKBFile\": \"Include in Knowledge Base\",\n        \"autoVectorCalc\": \"Auto Vector Calculation\",\n        \"vectorCalculated\": \"Vector Updated\",\n        \"vectorCalcCompleted\": \"Vector Calculation Completed\",\n        \"vectorCalcFailed\": \"Vector Calculation Failed\",\n        \"vectorDeleted\": \"Vector Deleted\",\n        \"vectorDeleteFailed\": \"Delete Vector Failed\",\n        \"batchCalcSuccess\": \"Successfully calculated vectors for {count} files\",\n        \"batchCalcPartial\": \"Calculation completed: {success} succeeded, {failed} failed\",\n        \"batchCalcFailed\": \"Batch vector calculation failed\",\n        \"batchDeleteSuccess\": \"Successfully deleted vectors for {count} files\",\n        \"batchDeletePartial\": \"Deletion completed: {success} succeeded, {failed} failed\",\n        \"batchDeleteFailed\": \"Batch vector deletion failed\",\n        \"noMarkdownFiles\": \"No Markdown files in folder\",\n        \"includedInKB\": \"Included in Knowledge Base\",\n        \"excludedFromKB\": \"Excluded from Knowledge Base\",\n        \"autoCalcEnabled\": \"Auto vector calculation enabled\",\n        \"autoCalcDisabled\": \"Auto vector calculation disabled\",\n        \"settingFailed\": \"Setting failed\",\n        \"confirmDeleteVectors\": \"Are you sure you want to delete vectors for {count} files?\"\n      },\n      \"folderView\": {\n        \"vectorDbNotEnabled\": \"Vector database not enabled\",\n        \"calculateVectors\": \"Calculate Vectors\",\n        \"indexed\": \"Indexed\",\n        \"vectorCount\": \"Vector Count\",\n        \"databaseSize\": \"Database Size\",\n        \"lastCalculated\": \"Last Calculated\",\n        \"never\": \"Never\",\n        \"calculating\": \"Calculating...\",\n        \"failed\": \"Failed\",\n        \"recalculateVectors\": \"Recalculate Vectors\",\n        \"skills\": \"Skills\",\n        \"skillNotFound\": \"Skill Not Found\",\n        \"skillNotFoundDesc\": \"Cannot find Skill with ID {id}\",\n        \"loadingSkills\": \"Loading Skills...\",\n        \"loadingSkill\": \"Loading Skill...\",\n        \"globalSkills\": \"Global Skills\",\n        \"workspaceSkills\": \"Workspace Skills\",\n        \"instructions\": \"Instructions\",\n        \"examples\": \"Examples\",\n        \"scripts\": \"Scripts\",\n        \"references\": \"References\",\n        \"assets\": \"Assets\"\n      },\n      \"error\": {\n        \"fileExists\": \"File name already exists\"\n      },\n      \"clipboard\": {\n        \"copied\": \"Copied to clipboard\",\n        \"cut\": \"Cut to clipboard\",\n        \"pasted\": \"Pasted successfully\",\n        \"pasteFailed\": \"Paste operation failed\",\n        \"empty\": \"Clipboard is empty\",\n        \"confirmOverwrite\": \"File already exists, do you want to overwrite it?\",\n        \"mark\": {\n          \"title\": \"Records\",\n          \"tooltip\": \"Use Records\",\n          \"description\": \"Convert records into content to insert into the article.\",\n          \"noRecords\": \"No records\",\n          \"ocrNoContent\": \"OCR did not recognize any content\"\n        },\n        \"question\": {\n          \"tooltip\": \"Q&A\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Reference text: \\n{content}\\nBased on the question: \\n{question}\\n, directly provide the answer content.\"\n        },\n        \"continue\": {\n          \"tooltip\": \"Continue\",\n          \"promptTemplate\": \"Based on the preceding text: \\n{content}\\n continue writing and return content not exceeding 100 words.\\nYou can reference the following text: \\n{endContent}\\n, but avoid duplicating its content.\"\n        },\n        \"polish\": {\n          \"tooltip\": \"Polish\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Polish this text: \\n{content}\\n, keep the language unchanged, fix typos and grammatical errors, directly return the polished result.\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"Simplify\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Simplify this text: \\n{content}\\n, this text is too verbose, reduce the word count by at least half, keep the language unchanged, directly return the optimized result.\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"Expand\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Expand this text: \\n{content}\\n, this text is too short, increase the word count by at least half, keep the language unchanged, directly return the expanded result.\"\n        },\n        \"translation\": {\n          \"tooltip\": \"Translate\",\n          \"description\": \"Translate the selected text\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Translate this text: \\n{content}\\n, into {language}, directly return the translated result.\"\n        },\n        \"notSupported\": \"Operation not supported\"\n      },\n      \"mobile\": {\n        \"cancel\": \"Cancel\",\n        \"create\": \"Create\",\n        \"save\": \"Save\",\n        \"emptyDir\": \"This folder is empty\",\n        \"root\": \"Root\",\n        \"openFiles\": \"Open files\",\n        \"remote\": \"Remote file\",\n        \"remoteFileNotPulled\": \"Cloud only · Tap to pull\",\n        \"remoteFolderOnly\": \"Cloud-only folder\",\n        \"file\": \"File\",\n        \"folder\": \"Folder\",\n        \"folderChildren\": \"{files} files · {folders} folders\",\n        \"filePlaceholder\": \"example.md\",\n        \"folderPlaceholder\": \"example-folder\"\n      },\n      \"deleteConfirm\": \"Are you sure you want to delete this file?\"\n    },\n    \"editor\": {\n      \"copySuccess\": \"Copy Success\",\n      \"copySuccessDescription\": \"Copied to clipboard\",\n      \"search\": {\n        \"placeholder\": \"Find in document\",\n        \"replacePlaceholder\": \"Replace with\",\n        \"caseSensitive\": \"Case sensitive\",\n        \"replace\": \"Replace\",\n        \"replaceAll\": \"Replace all\",\n        \"findPrev\": \"Previous\",\n        \"findNext\": \"Next\"\n      },\n      \"floatbar\": {\n        \"quote\": {\n          \"tooltip\": \"Quote\"\n        },\n        \"readAloud\": {\n          \"start\": \"Read Aloud\",\n          \"stop\": \"Stop Reading\",\n          \"loading\": \"Loading...\"\n        }\n      },\n      \"toolbar\": {\n        \"organize\": {\n          \"tooltip\": \"Organize Notes\"\n        },\n        \"mark\": {\n          \"title\": \"Records\",\n          \"tooltip\": \"Records\",\n          \"description\": \"Convert records into content to insert into the article.\",\n          \"noRecords\": \"No records\",\n          \"ocrNoContent\": \"OCR did not recognize any content\"\n        },\n        \"question\": {\n          \"tooltip\": \"Q&A\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Reference text: \\n{content}\\nBased on the question: \\n{question}\\n, directly provide the answer content.\"\n        },\n        \"continue\": {\n          \"tooltip\": \"Continue\",\n          \"promptTemplate\": \"Based on the preceding text: \\n{content}\\n continue writing and return content not exceeding 100 words.\\nYou can reference the following text: \\n{endContent}\\n, but avoid duplicating its content.\"\n        },\n        \"polish\": {\n          \"tooltip\": \"Polish\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Polish this text: \\n{content}\\n, keep the language unchanged, fix typos and grammatical errors, directly return the polished result.\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"Simplify\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Simplify this text: \\n{content}\\n, this text is too verbose, reduce the word count by at least half, keep the language unchanged, directly return the optimized result.\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"Expand\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Expand this text: \\n{content}\\n, this text is too short, increase the word count by at least half, keep the language unchanged, directly return the expanded result.\"\n        },\n        \"translation\": {\n          \"tooltip\": \"Translate\",\n          \"description\": \"Translate the selected text\",\n          \"selectContent\": \"Please select content first\",\n          \"promptTemplate\": \"Translate this text: \\n{content}\\n, into {language}, directly return the translated result.\",\n          \"fail\": \"Translation failed\",\n          \"failNoSelection\": \"Please select text to translate\",\n          \"translating\": \"Translating\",\n          \"translatingTo\": \"Translating to {language}...\",\n          \"success\": \"Translation complete\",\n          \"successTo\": \"Translated to {language}\",\n          \"customLanguage\": \"Custom language...\",\n          \"customLanguagePlaceholder\": \"Enter target language, e.g., English, Japanese, etc.\",\n          \"customLanguageEmpty\": \"Please enter target language\",\n          \"customLanguageExample\": \"e.g., English, Japanese, French, etc.\"\n        }\n      },\n      \"upload\": {\n        \"error\": \"Upload failed\",\n        \"needToken\": \"Upload images need to configure accessToken\",\n        \"uploading\": \"Uploading image\"\n      },\n      \"saveDialog\": {\n        \"title\": \"Save File\",\n        \"emptyContent\": \"Empty Content\",\n        \"emptyContentDesc\": \"Please enter content before saving\",\n        \"success\": \"Save Successful\",\n        \"successDesc\": \"File saved successfully\",\n        \"error\": \"Save Failed\",\n        \"errorDesc\": \"Failed to save file, please try again\"\n      }\n    },\n    \"footer\": {\n      \"wordCount\": \"Word Count\",\n      \"pull\": {\n        \"pull\": \"Pull\",\n        \"checking\": \"Checking for updates...\",\n        \"noUpdate\": \"No remote updates\",\n        \"clickToPull\": \"Click to pull remote updates\",\n        \"pullSuccess\": \"Pull Successful\",\n        \"pullFailed\": \"Pull Failed\",\n        \"ignored\": \"Ignored\",\n        \"ignoreUpdate\": \"Ignore This Update\"\n      },\n      \"sync\": {\n        \"push\": \"Push\",\n        \"pushed\": \"Pushed\",\n        \"syncing\": \"Pushing\",\n        \"syncFailed\": \"Push Failed\",\n        \"checkNetworkOrToken\": \"Please check network connection or token\",\n        \"quickSync\": \"Quick Sync\"\n      },\n      \"history\": {\n        \"loadingHistory\": \"Loading history\",\n        \"historyRecords\": \"History Records\",\n        \"noHistory\": \"No History\",\n        \"loading\": \"Loading\",\n        \"recordsCount\": \"records\",\n        \"filterQuickSync\": \"Filter Quick Syncs\",\n        \"committedAt\": \"committed at\",\n        \"pull\": \"Pull\",\n        \"quickSync\": \"Quick Sync\"\n      },\n      \"vectorCalc\": {\n        \"tooltip\": {\n          \"default\": \"Vector Index Status\",\n          \"none\": \"Click to start vector calculation\",\n          \"indexed\": \"Indexed\",\n          \"pending\": \"Pending update, click to calculate now\",\n          \"calculating\": \"Calculating...\"\n        },\n        \"status\": {\n          \"calculating\": \"Calculating\"\n        }\n      }\n    }\n  },\n  \"mobile\": {\n    \"chat\": {\n      \"drawer\": {\n        \"settings\": {\n          \"title\": \"Chat Settings\"\n        },\n        \"tools\": {\n          \"title\": \"Tools\",\n          \"newChat\": \"New Chat\",\n          \"start\": \"Start\"\n        },\n        \"attachments\": {\n          \"title\": \"Attachments\",\n          \"gallery\": \"Gallery\",\n          \"camera\": \"Camera\",\n          \"file\": \"File\",\n          \"linkNote\": \"Link Note\"\n        }\n      }\n    }\n  },\n  \"mcp\": {\n    \"selectServers\": \"MCP Servers\",\n    \"searchServers\": \"Search servers...\",\n    \"noServers\": \"MCP service not enabled\",\n    \"noServersFound\": \"No matching servers found\",\n    \"addServer\": \"Add server...\",\n    \"goToSettings\": \"Go to Settings\",\n    \"close\": \"Close\",\n    \"navigate\": \"Select\",\n    \"confirm\": \"Confirm\",\n    \"tools\": \"tools\",\n    \"connecting\": \"Connecting\",\n    \"disconnected\": \"Disconnected\"\n  },\n  \"recording\": {\n    \"title\": \"Voice Recording\",\n    \"description\": \"Click the microphone button to start recording, the system will automatically recognize and convert to text\",\n    \"recording\": \"Recording\",\n    \"paused\": \"Paused\",\n    \"ready\": \"Ready\",\n    \"processing\": \"Processing...\",\n    \"cancel\": \"Cancel\",\n    \"error\": \"Error\",\n    \"success\": \"Success\",\n    \"noModelConfigured\": \"Speech recognition model not configured, please configure in settings first\",\n    \"speechUnavailable\": \"The current speech recognition mode is unavailable. Check local speech support or model configuration.\",\n    \"fallbackToModel\": \"Local speech recognition is unavailable, so the app switched to model transcription automatically.\",\n    \"startError\": \"Unable to start recording\",\n    \"noAudioData\": \"No audio data recorded\",\n    \"transcriptionSuccess\": \"Speech recognition completed\",\n    \"transcriptionEmpty\": \"Recognition result is empty\",\n    \"transcriptionError\": \"Speech recognition failed\",\n    \"configureModel\": \"Configure model\",\n    \"retryTranscription\": \"Retry transcription\",\n    \"retrying\": \"Retrying...\",\n    \"retrySuccess\": \"Transcription updated\",\n    \"retryError\": \"Retry transcription failed\",\n    \"noContentDetected\": \"No content detected\",\n    \"doubleClickToSelectFile\": \"Double click to select audio file\",\n    \"mode\": {\n      \"builtin\": \"Browser Recognition\",\n      \"builtinDesc\": \"Free, real-time recognition\",\n      \"model\": \"AI Model Recognition\",\n      \"modelDesc\": \"Requires STT model, more accurate\"\n    }\n  },\n  \"footer\": {\n    \"wordCount\": \"Words\",\n    \"sync\": {\n      \"sync\": \"Sync\",\n      \"synced\": \"Synced\",\n      \"syncing\": \"Syncing\",\n      \"syncFailed\": \"Sync Failed\",\n      \"checkNetworkOrToken\": \"Please check network connection or token\",\n      \"quickSync\": \"Quick Sync\"\n    },\n    \"history\": {\n      \"loadingHistory\": \"Loading history\",\n      \"historyRecords\": \"History Records\",\n      \"noHistory\": \"No History\",\n      \"loading\": \"Loading\",\n      \"recordsCount\": \"records\",\n      \"filterQuickSync\": \"Filter Quick Syncs\",\n      \"committedAt\": \"committed at\",\n      \"pull\": \"Pull\",\n      \"quickSync\": \"Quick Sync\"\n    },\n    \"vectorCalc\": {\n      \"tooltip\": \"Vector Calculation: Auto-calculate 30s after editing, or click to calculate now\",\n      \"calculating\": \"Calculating\",\n      \"pending\": \"Pending {progress}%\",\n      \"synced\": \"Synced\"\n    }\n  },\n  \"quickRecord\": {\n    \"description\": \"Click to select a recording tool and quickly create records\"\n  },\n  \"editor\": {\n    \"placeholder\": \"Type / to open menu, or start writing...\",\n    \"outline\": {\n      \"title\": \"Outline\",\n      \"open\": \"Open Outline\",\n      \"close\": \"Close Outline\"\n    },\n    \"translation\": {\n      \"fail\": \"Translation failed\",\n      \"failNoSelection\": \"Please select text to translate\",\n      \"translating\": \"Translating...\",\n      \"translatingTo\": \"Translating to {language}...\",\n      \"success\": \"Translation complete\",\n      \"successTo\": \"Translated to {language}\",\n      \"customLanguageEmpty\": \"Please enter target language\",\n      \"customLanguageExample\": \"e.g., English, Japanese, French, etc.\"\n    },\n    \"quoteDisplay\": {\n      \"fromFile\": \"Quoted from {fileName}\",\n      \"line\": \"Quoted from {fileName} line {line}\",\n      \"lines\": \"Quoted from {fileName} lines {start}-{end}\"\n    },\n    \"bubbleMenu\": {\n      \"ai\": \"AI\",\n      \"polish\": \"Polish\",\n      \"concise\": \"Concise\",\n      \"expand\": \"Expand\",\n      \"translate\": \"Translate\",\n      \"translateSubtitle\": \"Translate to\",\n      \"quoteToChat\": \"Quote to Chat\",\n      \"link\": \"Link\",\n      \"linkPlaceholder\": \"Enter link URL\",\n      \"confirm\": \"Confirm\",\n      \"cancel\": \"Cancel\",\n      \"bold\": \"Bold\",\n      \"italic\": \"Italic\",\n      \"strike\": \"Strikethrough\",\n      \"underline\": \"Underline\",\n      \"inlineCode\": \"Inline Code\",\n      \"highlight\": \"Highlight\",\n      \"blockquote\": \"Quote\",\n      \"bulletList\": \"Bullet List\",\n      \"orderedList\": \"Numbered List\",\n      \"taskList\": \"Task List\",\n      \"codeBlock\": \"Code Block\",\n      \"languages\": {\n        \"English\": \"English\",\n        \"Japanese\": \"Japanese\",\n        \"Korean\": \"Korean\",\n        \"French\": \"French\",\n        \"German\": \"German\",\n        \"Spanish\": \"Spanish\",\n        \"Portuguese\": \"Portuguese\",\n        \"Russian\": \"Russian\",\n        \"Arabic\": \"Arabic\"\n      },\n      \"customLanguagePlaceholder\": \"Custom language...\"\n    },\n    \"aiSuggestion\": {\n      \"accept\": \"Accept\",\n      \"reject\": \"Reject\",\n      \"generating\": \"Generating...\",\n      \"abort\": \"Abort\"\n    },\n    \"image\": {\n      \"insert\": \"Insert Image\",\n      \"uploading\": \"Uploading...\",\n      \"uploadSuccess\": \"Image uploaded to image hosting\",\n      \"saveSuccess\": \"Image saved locally\",\n      \"uploadFailed\": \"Failed to insert image\",\n      \"sizeSmall\": \"Small (25%)\",\n      \"sizeMedium\": \"Medium (50%)\",\n      \"sizeLarge\": \"Large (75%)\",\n      \"sizeOriginal\": \"Original Size\",\n      \"editAlt\": \"Edit Alt Text\",\n      \"editSrc\": \"Edit URL\",\n      \"altPlaceholder\": \"Enter alt text...\",\n      \"srcPlaceholder\": \"Enter image URL...\",\n      \"delete\": \"Delete Image\",\n      \"confirm\": \"Confirm\",\n      \"cancel\": \"Cancel\"\n    },\n    \"mermaid\": {\n      \"rendering\": \"Rendering...\",\n      \"renderError\": \"Render error\",\n      \"clickToEdit\": \"Click to edit source\",\n      \"clickToAdd\": \"Click to add diagram\",\n      \"placeholder\": \"Enter Mermaid diagram code...\",\n      \"preview\": \"Preview\",\n      \"done\": \"Done\",\n      \"diagramTypes\": {\n        \"flowchart\": \"Flowchart\",\n        \"sequence\": \"Sequence\",\n        \"classDiagram\": \"Class Diagram\",\n        \"stateDiagram\": \"State Diagram\",\n        \"er\": \"ER Diagram\",\n        \"gantt\": \"Gantt\",\n        \"pie\": \"Pie Chart\",\n        \"journey\": \"Journey\"\n      },\n      \"templates\": {\n        \"flowchart\": \"graph TD\\n    A[Start] --> B[Process]\\n    B --> C[End]\",\n        \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: Hello\\n    Bob-->>Alice: Reply\",\n        \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n        \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n        \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n        \"gantt\": \"gantt\\n    title Project Plan\\n    dateFormat YYYY-MM-DD\\n    section Phase 1\\n    Task1 :a1, 2024-01-01, 30d\\n    section Phase 2\\n    Task2 :after a1, 20d\",\n        \"pie\": \"pie title Resource Allocation\\n    \\\"CPU\\\" : 45\\n    \\\"Memory\\\" : 30\\n    \\\"Storage\\\" : 25\",\n        \"journey\": \"journey\\n    title My Daily Work\\n    section Morning\\n    Commute : 7:00, 5\\n    Work : 9:00, 8\"\n      }\n    },\n    \"slashCommand\": {\n      \"groups\": {\n        \"ai\": \"AI\",\n        \"heading\": \"Heading\",\n        \"list\": \"List\",\n        \"block\": \"Block\",\n        \"align\": \"Align\",\n        \"embed\": \"Embed\",\n        \"math\": \"Math\",\n        \"chart\": \"Chart\"\n      },\n      \"items\": {\n        \"continue\": \"Continue\",\n        \"continueDesc\": \"AI continue writing content\",\n        \"heading1\": \"Heading 1\",\n        \"heading1Desc\": \"Large heading\",\n        \"heading2\": \"Heading 2\",\n        \"heading2Desc\": \"Medium heading\",\n        \"heading3\": \"Heading 3\",\n        \"heading3Desc\": \"Small heading\",\n        \"bulletList\": \"Bullet List\",\n        \"bulletListDesc\": \"Create a simple bullet list\",\n        \"orderedList\": \"Ordered List\",\n        \"orderedListDesc\": \"Create a numbered list\",\n        \"taskList\": \"Task List\",\n        \"taskListDesc\": \"Create a checklist with checkboxes\",\n        \"image\": \"Image\",\n        \"imageDesc\": \"Insert local or hosted image\",\n        \"table\": \"Table\",\n        \"tableDesc\": \"Insert a table\",\n        \"blockquote\": \"Quote\",\n        \"blockquoteDesc\": \"Capture quoted content\",\n        \"codeBlock\": \"Code Block\",\n        \"codeBlockDesc\": \"Capture code snippets\",\n        \"divider\": \"Divider\",\n        \"dividerDesc\": \"Create a horizontal divider\",\n        \"inlineMath\": \"Inline Math\",\n        \"inlineMathDesc\": \"Insert inline LaTeX formula\",\n        \"blockMath\": \"Block Math\",\n        \"blockMathDesc\": \"Insert block LaTeX formula\",\n        \"flowchart\": \"Flowchart\",\n        \"flowchartDesc\": \"Insert a flowchart\",\n        \"sequence\": \"Sequence Diagram\",\n        \"sequenceDesc\": \"Insert a sequence diagram\",\n        \"gantt\": \"Gantt Chart\",\n        \"ganttDesc\": \"Insert a Gantt chart\",\n        \"classDiagram\": \"Class Diagram\",\n        \"classDiagramDesc\": \"Insert a class diagram\",\n        \"stateDiagram\": \"State Diagram\",\n        \"stateDiagramDesc\": \"Insert a state diagram\",\n        \"pie\": \"Pie Chart\",\n        \"pieDesc\": \"Insert a pie chart\",\n        \"erDiagram\": \"ER Diagram\",\n        \"erDiagramDesc\": \"Insert an entity relationship diagram\",\n        \"journey\": \"Journey Map\",\n        \"journeyDesc\": \"Insert a user journey map\"\n      },\n      \"imageUpload\": {\n        \"success\": \"Upload successful\",\n        \"saveSuccess\": \"Save successful\",\n        \"savePath\": \"Saved to: {path}\",\n        \"failed\": \"Failed to insert image\"\n      }\n    }\n  },\n  \"tabContext\": {\n    \"close\": \"Close\",\n    \"closeOthers\": \"Close Others\",\n    \"closeAll\": \"Close All\",\n    \"closeLeft\": \"Close Left\",\n    \"closeRight\": \"Close Right\"\n  }\n}\n"
  },
  {
    "path": "messages/ja.json",
    "content": "{\n  \"app\": {\n    \"title\": \"ノート生成ツール\",\n    \"description\": \"あなたのAI搭載ノートアシスタント\"\n  },\n  \"common\": {\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"削除\",\n    \"confirm\": \"確認\",\n    \"edit\": \"編集\",\n    \"create\": \"作成\",\n    \"theme\": \"テーマ\",\n    \"light\": \"ライトモード\",\n    \"dark\": \"ダークモード\",\n    \"system\": \"システム設定に従う\",\n    \"pin\": \"ピン留め\",\n    \"unpin\": \"ピン留めを解除\",\n    \"settings\": \"設定\",\n    \"back\": \"戻る\",\n    \"sync\": \"同期\",\n    \"language\": \"言語\",\n    \"success\": \"成功\",\n    \"error\": \"失敗\",\n    \"defaultFileName\": \"無題のドキュメント\",\n    \"restartToApply\": \"、設定を有効にするにはアプリケーションを再起動してください\",\n    \"close\": \"閉じる\",\n    \"open\": \"開く\",\n    \"add\": \"追加\",\n    \"remove\": \"削除\",\n    \"search\": \"検索\",\n    \"filter\": \"フィルター\",\n    \"sort\": \"並べ替え\",\n    \"export\": \"エクスポート\",\n    \"import\": \"インポート\",\n    \"refresh\": \"更新\",\n    \"loading\": \"読み込み中...\",\n    \"warning\": \"警告\",\n    \"info\": \"情報\",\n    \"unsaved\": \"未保存\",\n    \"saving\": \"保存中...\",\n    \"configureSync\": \"同期を構成\"\n  },\n  \"settings\": {\n    \"defaultModels\": {\n      \"title\": \"デフォルトモデル\"\n    },\n    \"others\": \"その他\",\n    \"general\": {\n      \"title\": \"一般設定\",\n      \"desc\": \"ここでは、アプリケーションの基本設定を構成できます。インターフェースのテーマ、言語などのオプションが含まれます。\",\n      \"interface\": {\n        \"title\": \"インターフェース設定\",\n        \"theme\": {\n          \"title\": \"テーマ\",\n          \"desc\": \"アプリケーションの外観テーマを選択\",\n          \"options\": {\n            \"light\": \"ライトモード\",\n            \"dark\": \"ダークモード\",\n            \"system\": \"システム設定に従う\"\n          }\n        },\n        \"language\": {\n          \"title\": \"言語\",\n          \"desc\": \"アプリケーションの表示言語を選択\"\n        },\n        \"scale\": {\n          \"title\": \"インターフェーススケール\",\n          \"desc\": \"アプリケーションインターフェースの全体的なスケールを調整\",\n          \"placeholder\": \"スケール比率を選択\"\n        },\n        \"contentTextScale\": {\n          \"title\": \"本文スケール\",\n          \"desc\": \"エディターとチャットの Markdown コンテンツのテキストサイズを調整\"\n        },\n        \"fileManagerTextSize\": {\n          \"title\": \"ファイルマネージャーのテキストサイズ\",\n          \"desc\": \"ファイルマネージャーのファイルとフォルダーリストのテキストサイズを調整\"\n        },\n        \"recordTextSize\": {\n          \"title\": \"記録のテキストサイズ\",\n          \"desc\": \"記録リストの記録項目のテキストサイズを調整\"\n        },\n        \"customCss\": {\n          \"title\": \"カスタム CSS\",\n          \"desc\": \"カスタム CSS スタイルを追加してアプリケーションのデフォルトスタイルを上書き\",\n          \"button\": \"CSS を編集\",\n          \"dialogTitle\": \"カスタム CSS\",\n          \"dialogDesc\": \"以下にカスタム CSS コードを入力して、アプリケーションのデフォルトスタイルを上書きします。保存をクリックして変更を適用します。\",\n          \"placeholder\": \"ここにカスタム CSS コードを入力\",\n          \"save\": \"保存\",\n          \"cancel\": \"キャンセル\"\n        },\n        \"tray\": {\n          \"enabled\": {\n            \"title\": \"トレイを有効にする\",\n            \"desc\": \"ウィンドウを閉じる時にトレイに最小化するかアプリを終了するか選択\"\n          }\n        },\n        \"customTheme\": {\n          \"title\": \"自定义主题颜色\",\n          \"desc\": \"自定义应用的主题颜色，包括背景色、前景色、边框色等\",\n          \"button\": \"编辑颜色\",\n          \"dialogTitle\": \"自定义主题颜色\",\n          \"dialogDesc\": \"配置自定义主题颜色。颜色更改会实时保存并生效，同时覆盖亮色和暗色主题。\",\n          \"close\": \"閉じる\",\n          \"reset\": \"重置全部\",\n          \"tabs\": {\n            \"custom\": \"自定义\",\n            \"presets\": \"预设方案\",\n            \"importExport\": \"导入导出\"\n          },\n          \"export\": {\n            \"title\": \"导出配色方案\",\n            \"button\": \"生成导出代码\",\n            \"placeholder\": \"点击生成按钮将当前配色导出为代码\"\n          },\n          \"import\": {\n            \"title\": \"导入配色方案\",\n            \"button\": \"导入配色\",\n            \"placeholder\": \"粘贴配色方案的 JSON 代码\"\n          },\n          \"colors\": {\n            \"background\": \"背景色\",\n            \"foreground\": \"前景色\",\n            \"card\": \"卡片背景色\",\n            \"cardForeground\": \"卡片前景色\",\n            \"primary\": \"主色调\",\n            \"primaryForeground\": \"主色调前景色\",\n            \"secondary\": \"次要色调\",\n            \"secondaryForeground\": \"次要色调前景色\",\n            \"third\": \"第三色调\",\n            \"thirdForeground\": \"第三色调前景色\",\n            \"muted\": \"柔和色\",\n            \"mutedForeground\": \"柔和色前景色\",\n            \"accent\": \"强调色\",\n            \"accentForeground\": \"强调色前景色\",\n            \"border\": \"边框色\",\n            \"shadow\": \"阴影色\"\n          },\n          \"presets\": {\n            \"apply\": \"应用\",\n            \"reset\": {\n              \"name\": \"恢复默认\"\n            },\n            \"default\": {\n              \"name\": \"默认白色\"\n            },\n            \"ocean\": {\n              \"name\": \"海洋蓝\"\n            },\n            \"forest\": {\n              \"name\": \"森林绿\"\n            },\n            \"sunset\": {\n              \"name\": \"日落红\"\n            },\n            \"lavender\": {\n              \"name\": \"薰衣草紫\"\n            },\n            \"midnight\": {\n              \"name\": \"午夜暗\"\n            },\n            \"deepSea\": {\n              \"name\": \"深海蓝\"\n            },\n            \"darkForest\": {\n              \"name\": \"暗夜绿\"\n            },\n            \"darkViolet\": {\n              \"name\": \"紫罗兰暗\"\n            },\n            \"coralWarm\": {\n              \"name\": \"珊瑚暖\"\n            },\n            \"slateGray\": {\n              \"name\": \"石板灰\"\n            },\n            \"darkGold\": {\n              \"name\": \"暗夜金\"\n            },\n            \"beigeWarm\": {\n              \"name\": \"米黄暖\"\n            },\n            \"beigeDark\": {\n              \"name\": \"米黄暗\"\n            }\n          }\n        }\n      },\n      \"tools\": {\n        \"title\": \"ツール設定\",\n        \"chatToolbar\": {\n          \"title\": \"チャットツールバー\",\n          \"desc\": \"チャットツールバーボタンの表示順序と可視性をカスタマイズ\",\n          \"button\": \"設定\",\n          \"dialogTitle\": \"チャットツールバーを構成\",\n          \"dialogDesc\": \"ツールをドラッグして順序を調整し、スイッチを使用して表示または非表示を制御\",\n          \"groups\": {\n            \"pc\": \"PC\",\n            \"mobile\": \"モバイル\",\n            \"bottom\": \"下部ツールバー\",\n            \"topLeft\": \"上部ツールバー - 左側\",\n            \"topRight\": \"上部ツールバー - 右側\"\n          }\n        },\n        \"recordToolbar\": {\n          \"title\": \"記録ツールバー\",\n          \"desc\": \"記録ツールバーボタンの表示順序と可視性をカスタマイズ\",\n          \"button\": \"設定\",\n          \"dialogTitle\": \"記録ツールバーを構成\",\n          \"dialogDesc\": \"ツールをドラッグして順序を調整し、スイッチを使用して表示または非表示を制御\"\n        },\n        \"desc\": \"配置各种工具栏按钮的显示和排序\"\n      }\n    },\n    \"rag\": {\n      \"title\": \"知識庫\",\n      \"desc\": \"ここでは、知識庫に関する設定を構成できます。知識庫は、RAG技術に基づいて、埋め込みモデルを使用してテキストをベクトルに変換し、ベクトル検索を介して智能的な検索と智能的な回答を達成します。\",\n      \"settingsTitle\": \"パラメータ設定\",\n      \"settingsDesc\": \"パラメータを調整することで、知識庫の検索結果をより正確に制御できます。\",\n      \"deleteVectorConfirm\": \"知識庫を空にしますか？\",\n      \"deleteVectorSuccess\": \"知識庫を空にしました\",\n      \"enable\": \"知識庫検索を有効にする\",\n      \"enableDesc\": \"有効にすると、AI は回答問題時にあなたのノート内容を検索して、より正確な回答を提供します。\",\n      \"chunkSize\": \"ブロックサイズ\",\n      \"chunkSizeDesc\": \"テキストをブロックに分割する最大文字数。より大きなブロックはより多くの文脈を含む可能性がありますが、ベクトル計算の複雑さを増加させます。\",\n      \"chunkOverlap\": \"重叠サイズ\",\n      \"chunkOverlapDesc\": \"テキストブロック間の重叠文字数。より大きな重叠は上下文の連続性を維持できます。\",\n      \"resultCount\": \"検索結果数\",\n      \"resultCountDesc\": \"検索時に返される関連するドキュメント数。数が多いと提供される情報がより豊富かもしれませんが、ノイズも増加する可能性があります。\",\n      \"similarityThreshold\": \"類似度閾値\",\n      \"similarityThresholdDesc\": \"ドキュメントとクエリの最小類似度閾値。この閾値を超えるドキュメントのみが返されます。値の範囲は 0.0-1.0、高いほど厳格です。\",\n      \"resetToDefaults\": \"デフォルト値に戻す\",\n      \"deleteVector\": \"知識庫を空にする\",\n      \"topPDesc\": \"Top P パラメータはモデルが生成するテキストの多様性を制御します。値が小さいほど出力は決定论的になり、値が大きいほど多様になります。\"\n    },\n    \"mcp\": {\n      \"title\": \"MCP\",\n      \"desc\": \"Model Context Protocol により、AI は外部ツールを呼び出してリソースにアクセスでき、AI の能力を拡張します。\",\n      \"servers\": \"サーバーリスト\",\n      \"serversDesc\": \"MCP サーバー設定を管理します。各サーバーは異なるツールとリソースを提供できます。\",\n      \"addServer\": \"サーバーを追加\",\n      \"addFirstServer\": \"最初のサーバーを追加\",\n      \"editServer\": \"サーバーを編集\",\n      \"serverName\": \"サーバー名\",\n      \"serverNamePlaceholder\": \"例：ファイルシステムサーバー\",\n      \"serverEnabled\": \"サーバーを有効にする\",\n      \"serverEnabledDesc\": \"有効にすると、このサーバーは自動的に接続してツールを提供します。\",\n      \"serverType\": \"サーバータイプ\",\n      \"stdio\": \"ローカルコマンド\",\n      \"http\": \"HTTP サービス\",\n      \"command\": \"コマンド\",\n      \"args\": \"引数\",\n      \"argsDesc\": \"コマンドライン引数、スペースで区切る\",\n      \"env\": \"環境変数\",\n      \"envDesc\": \"JSON 形式の環境変数設定\",\n      \"url\": \"サービス URL\",\n      \"headers\": \"リクエストヘッダー\",\n      \"headersDesc\": \"JSON 形式の HTTP リクエストヘッダー\",\n      \"testConnection\": \"接続テスト\",\n      \"test\": \"テスト\",\n      \"testSuccess\": \"接続テスト成功\",\n      \"testFailed\": \"接続テスト失敗\",\n      \"connected\": \"接続済み\",\n      \"connecting\": \"接続中\",\n      \"disconnected\": \"未接続\",\n      \"error\": \"エラー\",\n      \"tools\": \"ツール\",\n      \"noServers\": \"MCP サービスが有効になっていません\",\n      \"noServersFound\": \"一致するサーバーが見つかりません\",\n      \"serverAdded\": \"サーバーを追加しました\",\n      \"serverUpdated\": \"サーバーを更新しました\",\n      \"serverDeleted\": \"サーバーを削除しました\",\n      \"deleteServerTitle\": \"サーバーを削除\",\n      \"deleteServerDesc\": \"このサーバーを削除してもよろしいですか？この操作は元に戻せません。\",\n      \"nameRequired\": \"サーバー名を入力してください\",\n      \"commandRequired\": \"コマンドを入力してください\",\n      \"urlRequired\": \"サービス URL を入力してください\",\n      \"toolBrowser\": \"ツールブラウザ\",\n      \"searchTools\": \"ツールを検索...\",\n      \"noToolsFound\": \"ツールが見つかりません\",\n      \"parameters\": \"パラメータ\",\n      \"testAll\": \"すべての接続をテスト\",\n      \"testAllCompleted\": \"すべての接続テストが完了しました\",\n      \"testAllFailed\": \"接続テストに失敗しました\",\n      \"save\": \"保存\",\n      \"cancel\": \"キャンセル\",\n      \"delete\": \"削除\",\n      \"importJson\": \"JSON をインポート\",\n      \"jsonImportTitle\": \"JSON からサーバー設定をインポート\",\n      \"jsonImportDesc\": \"MCP サーバーの mcpServers 設定形式を貼り付けます\",\n      \"jsonInput\": \"JSON 設定\",\n      \"jsonInputHelp\": \"mcpServers 形式をサポート、サーバー名が自動的に key として使用されます\",\n      \"jsonRequired\": \"JSON 設定を入力してください\",\n      \"jsonEmpty\": \"JSON 設定は空にできません\",\n      \"jsonInvalidJson\": \"JSON 形式が無効です\",\n      \"jsonInvalidFormat\": \"設定形式が無効です、name と type フィールドを含む必要があります\",\n      \"jsonInvalidType\": \"サーバータイプは stdio または http である必要があります\",\n      \"jsonMissingCommand\": \"stdio タイプのサーバーは command を指定する必要があります\",\n      \"jsonMissingUrl\": \"http タイプのサーバーは url を指定する必要があります\",\n      \"jsonImportSuccess\": \"{count} 個のサーバーを正常にインポートしました\",\n      \"jsonImportSkipped\": \"{count} 個の既存サーバーをスキップしました\",\n      \"jsonImportNoServers\": \"サーバーがインポートされませんでした\",\n      \"import\": \"インポート\",\n      \"mobileHttpOnlyTitle\": \"ローカルコマンド MCP はデスクトップ専用です\",\n      \"mobileHttpOnlyDesc\": \"ローカルコマンド型 MCP サーバーはデスクトップでのみ利用できます。モバイルでは現在 HTTP MCP のみサポートします。\",\n      \"runtimeEnvironment\": \"ランタイム環境\",\n      \"runtimeEnvironmentDesc\": \"MCP サーバーをテストする前に、必要なローカルランタイムが利用可能か確認します。\",\n      \"checkEnvironment\": \"環境を確認\",\n      \"recheckEnvironment\": \"再チェック\",\n      \"runtimeCheckFailed\": \"環境チェックに失敗しました\",\n      \"detectedLauncher\": \"検出されたランチャー\",\n      \"runtimeInstalled\": \"インストール済み\",\n      \"runtimeMissing\": \"未検出\",\n      \"runtimeVersion\": \"バージョン\",\n      \"runtimeInstalledSummary\": \"{installed}/{total} がインストール済み\",\n      \"showRuntimeDetails\": \"ランタイムの詳細を表示\",\n      \"hideRuntimeDetails\": \"ランタイムの詳細を隠す\",\n      \"runtimeNotChecked\": \"このランタイムはまだ確認していません。\",\n      \"runtimeCurrentUserScope\": \"対応している場合、推奨コマンドは現在のユーザー環境にインストールします。\",\n      \"runtimeManualOnly\": \"このプラットフォームでは自動インストールに対応していません。手動でインストールしてから再チェックしてください。\",\n      \"installRuntime\": \"ランタイムをインストール\",\n      \"runtimeInstallTitle\": \"ランタイムをインストール\",\n      \"runtimeInstallDesc\": \"確認後、NoteGen は以下のインストールコマンドを実行します。\",\n      \"runtimeInstallPreparing\": \"インストールを準備中\",\n      \"runtimeInstallRunning\": \"インストール中\",\n      \"runtimeInstallCompleted\": \"インストール完了\",\n      \"runtimeInstallCancelled\": \"キャンセル済み\",\n      \"runtimeInstallFailedState\": \"インストール失敗\",\n      \"runtimeInstallLogs\": \"インストールログ\",\n      \"runtimeInstallWaitingLogs\": \"インストール出力を待機中...\",\n      \"runtimeInstallClose\": \"閉じる\",\n      \"runtimeInstallCancel\": \"インストールを停止\",\n      \"runtimeInstallCancelledByUser\": \"ユーザーがインストールのキャンセルを要求しました。\",\n      \"runtimeInstallCancelFailed\": \"インストールの停止に失敗しました\",\n      \"runtimeInstallSuccess\": \"ランタイムのインストールが完了しました\",\n      \"runtimeInstallFailed\": \"ランタイムのインストールに失敗しました\",\n      \"runtimeNoGuidedSupport\": \"このコマンドにはまだガイド付きランタイム補助がありません。\"\n      ,\n      \"enableTitle\": \"启用 MCP 功能\",\n      \"enableDesc\": \"启用后，AI 可以调用配置的 MCP 服务器提供的工具。\"\n    },\n    \"editor\": {\n      \"title\": \"エディター設定\",\n      \"interfaceSettings\": \"インターフェース設定\",\n      \"desc\": \"ここでは、エディターをカスタマイズして、より適した書き込み体験を提供できます。\",\n      \"centeredContent\": \"コンテンツを中央に表示\",\n      \"centeredContentDesc\": \"有効にすると、コンテンツを中央に表示し、両側に余白を設けます。\",\n      \"outlineEnable\": \"アウトラインを有効にする\",\n      \"outlineEnableDesc\": \"アウトラインを有効にすると、アウトラインが表示されます。\",\n      \"outlinePosition\": \"アウトライン位置\",\n      \"outlinePositionDesc\": \"アウトライン位置を設定します。\",\n      \"outlinePositionOptions\": {\n        \"left\": \"左側\",\n        \"right\": \"右側\"\n      },\n      \"showUndoRedo\": \"元に戻す/やり直しボタン\",\n      \"showUndoRedoDesc\": \"エディターのタブバーに元に戻すボタンとやり直しボタンを表示します。\",\n      \"completion\": {\n        \"title\": \"自動補完\",\n        \"model\": {\n          \"title\": \"自動補完モデル\",\n          \"desc\": \"エディターの AI インライン補完に使用するモデルを選択\"\n        }\n      },\n      \"commit\": {\n        \"title\": \"自動コミットメッセージ\",\n        \"model\": {\n          \"title\": \"コミットモデル\",\n          \"desc\": \"ファイルの変更に基づいて Git コミットメッセージを自動生成するためのモデル\"\n        }\n      },\n      \"mermaid\": {\n        \"title\": \" диаграмма\",\n        \"rendering\": \"レンダリング中...\",\n        \"renderError\": \"レンダリングエラー\",\n        \"clickToEdit\": \"クリックしてソースを編集\",\n        \"clickToAdd\": \"クリックして диаграммаを追加\",\n        \"placeholder\": \"Mermaid диаграммаコードを入力...\",\n        \"preview\": \"プレビュー\",\n        \"done\": \"完了\",\n        \"diagramTypes\": {\n          \"flowchart\": \"フローチャート\",\n          \"sequence\": \"シーケンス\",\n          \"classDiagram\": \"クラス図\",\n          \"stateDiagram\": \"ステート図\",\n          \"er\": \"ER図\",\n          \"gantt\": \"ガントチャート\",\n          \"pie\": \"円グラフ\",\n          \"journey\": \"ジャーニー\"\n        },\n        \"templates\": {\n          \"flowchart\": \"graph TD\\n    A[開始] --> B[処理]\\n    B --> C[終了]\",\n          \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: こんにちは\\n    Bob-->>Alice: 返信\",\n          \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n          \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n          \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n          \"gantt\": \"gantt\\n    title プロジェクト計画\\n    dateFormat YYYY-MM-DD\\n    section 第一フェーズ\\n    タスク1 :a1, 2024-01-01, 30d\\n    section 第二フェーズ\\n    タスク2 :after a1, 20d\",\n          \"pie\": \"pie title リソース割り当て\\n    \\\"CPU\\\" : 45\\n    \\\"メモリ\\\" : 30\\n    \\\"ストレージ\\\" : 25\",\n          \"journey\": \"journey\\n    title 私の日常工作\\n    section 午前\\n    通勤 : 7:00, 5\\n    仕事 : 9:00, 8\"\n        }\n      }\n    },\n    \"record\": {\n      \"title\": \"記録設定\",\n      \"desc\": \"ここでは、記録関連の設定を構成できます。記録の説明やツールバーの設定などが含まれます。\",\n      \"model\": {\n        \"title\": \"モデル設定\",\n        \"markDesc\": {\n          \"title\": \"記録説明\",\n          \"desc\": \"OCR で認識された記録を処理し、記録の説明を生成するためのモデル\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"ツールバー設定\",\n        \"recordToolbar\": {\n          \"title\": \"記録ツールバー\",\n          \"desc\": \"記録ツールバーボタンの表示順序と可視性をカスタマイズ\",\n          \"button\": \"設定\",\n          \"text\": {\n            \"desc\": \"记录文本内容\"\n          },\n          \"recording\": {\n            \"desc\": \"录音记录功能\"\n          },\n          \"scan\": {\n            \"desc\": \"扫描识别图片中的文字\"\n          },\n          \"image\": {\n            \"desc\": \"上传图片到笔记\"\n          },\n          \"link\": {\n            \"desc\": \"记录网页链接\"\n          },\n          \"file\": {\n            \"desc\": \"上传文件到笔记\"\n          },\n          \"todo\": {\n            \"desc\": \"创建待办事项\"\n          }\n        }\n      }\n    },\n    \"uploadStore\": {\n      \"uploadConfirm\": \"同期リポジトリをプライベートに設定してください。\",\n      \"downloadConfirm\": \"ダウンロード配置をローカル配置に上書きし、再起動して有効になります。\",\n      \"uploadSuccess\": \"配置を成功しました\",\n      \"downloadSuccess\": \"配置を成功しました\",\n      \"upload\": \"Upload\",\n      \"download\": \"Download\"\n    },\n    \"prompt\": {\n      \"title\": \"マスク\",\n      \"promptTitle\": \"マスク名称\",\n      \"desc\": \"ここでマスクを追加・管理し、AIがあなたのニーズをよりよく理解できるようにします。\",\n      \"addPrompt\": \"マスクを追加\",\n      \"selectPrompt\": \"マスクを選択\",\n      \"configPrompt\": \"マスクを設定\",\n      \"noContent\": \"内容がありません\",\n      \"addPromptDesc\": \"マスク名と内容を入力し、AIがあなたのニーズをよりよく理解できるようにしてください。\",\n      \"promptTitlePlaceholder\": \"マスク名を入力してください\",\n      \"promptContentPlaceholder\": \"マスク内容を入力してください\",\n      \"promptContent\": \"マスク内容\",\n      \"optimizePrompt\": \"プロンプト最適化\",\n      \"optimizing\": \"最適化中...\",\n      \"optimizeSuccess\": \"プロンプトの最適化が成功しました\",\n      \"optimizeFailed\": \"プロンプトの最適化に失敗しました。後でもう一度お試しください\",\n      \"noContentToOptimize\": \"まずプロンプト内容を入力してください\"\n    },\n    \"memories\": {\n      \"title\": \"記憶管理\",\n      \"desc\": \"AI長期記憶機能で、AIがあなたの執筆設定、知識ベース、メモ習慣を記憶します。\",\n      \"stats\": {\n        \"total\": \"総記憶数\",\n        \"preferences\": \"設定\",\n        \"knowledge\": \"知識\",\n        \"memories\": \"记忆\"\n      },\n      \"form\": {\n        \"title\": \"新しい記憶を追加\",\n        \"contentLabel\": \"記憶の内容\",\n        \"contentPlaceholder\": \"例：中国語で回答してほしい、Reactエキスパートです...\",\n        \"categoryLabel\": \"タイプ\",\n        \"preferenceDesc\": \"設定（言語、形式、スタイルなど）\",\n        \"knowledgeDesc\": \"知識（事実、経験、専門分野など）\",\n        \"save\": \"記憶を保存\",\n        \"saving\": \"保存中...\",\n        \"categoryDescription\": \"记忆分为两种类型：\",\n        \"preferenceDescription\": \"偏好：语言、格式、风格等设置，每次对话都会自动加载\",\n        \"memoryDescription\": \"记忆：事实、经验、专长等信息，根据对话内容智能匹配\",\n        \"preferenceLabel\": \"偏好\",\n        \"memoryLabel\": \"记忆\",\n        \"memoryDesc\": \"事实、经验、专长等\"\n      },\n      \"listTitle\": \"マイ記憶\",\n      \"addMemory\": \"記憶を追加\",\n      \"empty\": \"まだ記憶がありません。最初の記憶を追加しましょう！\",\n      \"emptyHint\": \"手動で記憶を追加するか、会話で「覚えて」「これを覚えて」などのフレーズを使うとAIが自動的に記憶を作成します。\",\n      \"preference\": \"設定\",\n      \"knowledge\": \"知識\",\n      \"replaced\": \"置換済み\",\n      \"accessCount\": \"{count} 回アクセス\",\n      \"tabs\": {\n        \"all\": \"すべて\",\n        \"preference\": \"設定\",\n        \"knowledge\": \"知識\",\n        \"memory\": \"记忆\"\n      },\n      \"success\": \"成功\",\n      \"saved\": \"記憶を保存しました\",\n      \"updated\": \"記憶を更新しました（類似の記憶を置換）\",\n      \"deleted\": \"記憶を削除しました\",\n      \"cleared\": \"すべての記憶をクリアしました\",\n      \"found\": \"{count} 件の記憶が見つかりました\",\n      \"error\": \"エラー\",\n      \"errorEmpty\": \"記憶の内容を入力してください\",\n      \"errorSave\": \"保存に失敗しました\",\n      \"errorDelete\": \"削除に失敗しました\",\n      \"errorList\": \"記憶リストの取得に失敗しました\",\n      \"errorEmbedding\": \"埋め込みの生成に失敗しました。埋め込みモデルの設定を確認してください\",\n      \"errorClear\": \"クリアに失敗しました\",\n      \"memory\": \"记忆\"\n    },\n    \"defaultModel\": {\n      \"title\": \"デフォルトモデル\",\n      \"desc\": \"ここでは、異なるシーンに応じて異なるモデルを使用し、効率を高めコストを削減できます。\",\n      \"tooltip\": \"メインモデルを使用\",\n      \"noModel\": \"使用しない\",\n      \"placeholder\": \"モデルを選択または検索してください\",\n      \"main\": \"メインモデル\",\n      \"options\": {\n        \"primaryModel\": {\n          \"title\": \"メインモデル\",\n          \"desc\": \"すべてのシーンのメインモデルとして、他の会話モデルがデフォルトモデルを選択していない場合、このモデルを使用します。\"\n        },\n        \"markDesc\": {\n          \"title\": \"記録の説明\",\n          \"desc\": \"OCR認識後の記録を処理し、記録の説明を生成します。\"\n        },\n        \"placeholder\": {\n          \"title\": \"AI提案\",\n          \"desc\": \"記録ページのAI会話プレースホルダーコンテンツ生成用のAI提案プロンプト。\"\n        },\n        \"completion\": {\n          \"title\": \"高速補完\",\n          \"desc\": \"Markdownエディタ用のAIインライン補完、GitHub Copilotに類似、素早く続きの内容を生成します。\"\n        },\n        \"commit\": {\n          \"title\": \"コミットメッセージを自動生成\",\n          \"desc\": \"Gitコミットメッセージを自動生成するために使用され、ファイルの内容変更に基づいて記述的なコミットメッセージをインテリジェントに生成します。\"\n        },\n        \"embedding\": {\n          \"title\": \"埋め込みモデル\",\n          \"desc\": \"テキスト埋め込みとベクトル化のシナリオに使用されます。\"\n        },\n        \"reranking\": {\n          \"title\": \"再ランキングモデル\",\n          \"desc\": \"検索結果の並べ替えと最適化に使用されます。\"\n        },\n        \"condense\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"用于压缩历史对话内容，节省 token 使用量\"\n        }\n      },\n      \"mainModel\": \"メインモデル\"\n    },\n    \"readAloud\": {\n      \"title\": \"音声読み上げ\",\n      \"desc\": \"ここでは、音声読み上げ関連の設定を構成し、チャットコンテンツに音声再生機能を提供できます。\",\n      \"noModel\": \"モデルを使用しない\",\n      \"options\": {\n        \"audioModel\": {\n          \"title\": \"音声モデル\",\n          \"desc\": \"テキストから音声への変換にAIモデルを選択し、様々な音声タイプとパラメータ設定をサポートします。\"\n        },\n        \"speed\": {\n          \"title\": \"読み上げ速度\",\n          \"desc\": \"音声の再生速度を調整します。0.25倍から4倍の範囲で設定でき、1倍が通常の速度です。\"\n        }\n      }\n    },\n    \"about\": {\n      \"title\": \"このアプリについて\",\n      \"desc\": \"記録と執筆に特化したメモ取りアシスタント。\",\n      \"version\": \"NoteGen v{version}\",\n      \"checkReleases\": \"過去のバージョンを確認\",\n      \"language\": \"言語\",\n      \"checkUpdate\": \"アップデートを確認\",\n      \"checkError\": \"アップデートの確認に失敗しました\",\n      \"updateAvailable\": \"新しいバージョンが利用可能です\",\n      \"updateDownloading\": \"更新中 {downloaded} / {contentLength}\",\n      \"updateInstalled\": \"アプリを再起動\",\n      \"noUpdate\": \"現在は最新バージョンです\",\n      \"ignoreVersion\": \"このバージョンを無視\",\n      \"ignoreVersionSuccess\": \"このバージョンの更新を無視しました\",\n      \"items\": {\n        \"home\": {\n          \"title\": \"公式サイト\",\n          \"buttonName\": \"表示\",\n          \"desc\": \"公式サイトを表示して、NoteGenの詳細を確認できます。\"\n        },\n        \"guide\": {\n          \"title\": \"ガイド\",\n          \"buttonName\": \"表示\",\n          \"desc\": \"配置ガイドを表示して、モデル、同期などの情報の設定方法を学ぶことができます。\"\n        },\n        \"github\": {\n          \"title\": \"GitHub\",\n          \"buttonName\": \"表示\",\n          \"desc\": \"NoteGenが役立った場合は、鼓励するには、GitHubにスターを付けてください！\"\n        },\n        \"releases\": {\n          \"title\": \"更新日志\",\n          \"buttonName\": \"表示\",\n          \"desc\": \"更新日志を表示して、NoteGenの更新内容を確認できます。\"\n        },\n        \"issues\": {\n          \"title\": \"問題報告\",\n          \"buttonName\": \"報告\",\n          \"desc\": \"NoteGenにバグを見つけた場合は、ここに報告してください。\"\n        },\n        \"discussions\": {\n          \"title\": \"ディスカッション\",\n          \"buttonName\": \"参加\",\n          \"desc\": \"作者や他のユーザーとディスカッションを参加できます。\"\n        }\n      }\n    },\n    \"sync\": {\n      \"title\": \"同期\",\n      \"desc\": \"ここでは、同期リポジトリを設定することができます。ログ、markdownファイル、システム設定などの情報を同期するのに役立ちます。\",\n      \"selectPlatform\": \"同期プラットフォームを選択\",\n      \"platformSettings\": \"プラットフォームを選択\",\n      \"settings\": \"同期設定\",\n      \"platformDesc\": \"Tokenとリポジトリ情報を設定して同期を有効にします\",\n      \"moreSettings\": \"その他の設定\",\n      \"repoStatus\": \"リポジトリの状態\",\n      \"syncRepo\": \"同期リポジトリ\",\n      \"syncRepoDesc\": \"執筆中のMarkdownファイルを同期\",\n      \"imageRepo\": \"画像ホスティング倉庫\",\n      \"imageRepoDesc\": \"あなたの画像を画像ホスティングリポジトリに同期します。\",\n      \"status\": {\n        \"connected\": \"接続済み\",\n        \"disconnected\": \"未接続\",\n        \"failed\": \"接続失敗\",\n        \"unconfigured\": \"未設定\"\n      },\n      \"uploadRecords\": \"レコードと設定をアップロード\",\n      \"downloadConfig\": \"レコードと設定をダウンロード\",\n      \"cloudSync\": \"レコードと設定の同期\",\n      \"localBackupAll\": \"ローカルバックアップ（全て）\",\n      \"private\": \"非公開\",\n      \"public\": \"公開\",\n      \"createdAt\": \"{time} に作成\",\n      \"updatedAt\": \"最終更新: {time}\",\n      \"newToken\": \"作成 access token\",\n      \"newTokenDesc\": \"新しいトークンを作成する際は必ずrepo権限を選択してください。設定後、自動的にプライベートリポジトリと画像ホスティングリポジトリが作成されます。\",\n      \"giteeTokenDesc\": \"Giteeの個人アクセストークンはデータ同期に使用されます。リポジトリの読み書き権限が必要です。設定後、自動的にファイルリポジトリ（プライベート）と画像リポジトリが作成されます。\",\n      \"imageRepoSetting\": \"画像ホスティングを有効化\",\n      \"imageRepoSettingDesc\": \"画像ホスティングリポジトリが設定されています。有効にすると画像はそちらに保存され、無効の場合はローカル保存となります。\",\n      \"jsdelivrSetting\": \"jsDelivr\",\n      \"jsdelivrSettingDesc\": \"jsdelivrを利用して画像アクセスを高速化します。\",\n      \"autoSync\": \"自動同期\",\n      \"autoSyncDesc\": \"有効にすると、エディターは入力停止から10秒後にGitHubに自動同期します\",\n      \"giteeAutoSyncDesc\": \"有効にすると、エディターは入力停止から10秒後にGiteeに自動同期します\",\n      \"customSyncRepo\": \"カスタム同期リポジトリ名\",\n      \"customSyncRepoDesc\": \"空白の場合はデフォルトのリポジトリ名を使用\",\n      \"customImageRepo\": \"カスタム画像リポジトリ名\",\n      \"customImageRepoDesc\": \"空白の場合はデフォルトのリポジトリ名を使用\",\n      \"backupMethod\": \"バックアップ方法\",\n      \"backupMethodDesc\": \"主要バックアップ方法として設定すると、文章作成中のすべての同期関連機能が現在のバックアップ方法を使用します（画像ホスティング機能を除く）\",\n      \"createRepo\": \"リポジトリを作成\",\n      \"creating\": \"作成中\",\n      \"checkRepo\": \"リポジトリを確認\",\n      \"checking\": \"確認中\",\n      \"enterToken\": \"Access Tokenを入力してください\",\n      \"enterTokenHint\": \"リポジトリ的状态を確認するには、先にAccess Tokenを入力してください\",\n      \"gitlabInstanceType\": \"GitLab インスタンスタイプ\",\n      \"gitlabInstanceTypeDesc\": \"接続する GitLab インスタンスタイプを選択してください\",\n      \"gitlabInstanceTypePlaceholder\": \"GitLab インスタンスタイプを選択してください\",\n      \"gitlabInstanceTypeOptions\": {\n        \"selfHosted\": \"自建インスタンス\",\n        \"selfHostedDesc\": \"自建 GitLab インスタンスの URL を入力してください（例：https://gitlab.example.com）\"\n      },\n      \"gitlabAccessTokenDesc\": \"{instanceDisplayName}でパーソナルアクセストークンを作成するには、api権限が必要です\",\n      \"autoSyncOptions\": {\n        \"placeholder\": \"自動同期時間\",\n        \"disabled\": \"無効\",\n        \"2s\": \"2 秒\",\n        \"3s\": \"3 秒\",\n        \"5s\": \"5 秒\",\n        \"10s\": \"10 秒\",\n        \"20s\": \"20 秒\",\n        \"30s\": \"30 秒\",\n        \"1m\": \"1 分\",\n        \"2m\": \"2 分\"\n      },\n      \"autoPullOnOpen\": \"ファイルを開く時に自動プル\",\n      \"autoPullOnOpenDesc\": \"ファイルを開く時、リモートに新しいバージョンがある場合は自動的にプルしてローカルを上書き\",\n      \"autoPullOnSwitch\": \"ファイルを切り替える時に自動プル\",\n      \"autoPullOnSwitchDesc\": \"他のファイルに切り替える時、リモートに新しいバージョンがある場合は自動的にプルしてローカルを上書き\",\n      \"exclusions\": {\n        \"title\": \"同期除外設定\",\n        \"desc\": \"以下の設定はデバイス固有のものであるため、デバイス間で同期されません\",\n        \"workspacePath\": \"ワークスペースパス\",\n        \"workspaceHistory\": \"ワークスペース履歴\",\n        \"assetsPath\": \"リソースパス\",\n        \"uiScale\": \"UIスケール\",\n        \"contentTextScale\": \"コンテンツテキストスケール\",\n        \"customCss\": \"カスタムCSS\",\n        \"reason\": \"これらの設定はデバイスによって異なる場合があるため、同期から除外することでパスエラーなどの問題を回避できます\"\n      },\n      \"settingsSync\": {\n        \"uploadSuccess\": \"設定のアップロードに成功しました\",\n        \"uploadFailed\": \"設定のアップロードに失敗しました\",\n        \"downloadSuccess\": \"設定のダウンロードに成功しました\",\n        \"downloadFailed\": \"設定のダウンロードに失敗しました\",\n        \"autoSync\": \"アップロード/ダウンロード時に設定が自動的に同期されます（ワークスペースパスなどのデバイス固有の設定を除く）\"\n      },\n      \"defaultRepoName\": \"默认: {name}\",\n      \"giteaInstanceType\": \"Gitea 实例类型\",\n      \"giteaInstanceTypeDesc\": \"选择要连接的 Gitea 实例类型\",\n      \"giteaInstanceTypePlaceholder\": \"选择 Gitea 实例类型\",\n      \"giteaInstanceTypeOptions\": {\n        \"selfHosted\": \"自建实例\",\n        \"selfHostedDesc\": \"输入您的自建 Gitea 服务器地址（如：https://gitea.example.com）\"\n      },\n      \"giteaAccessTokenDesc\": \"在 {instanceDisplayName} 创建个人访问令牌，需要完整的 repository 权限\",\n      \"s3\": {\n        \"title\": \"S3 同期\",\n        \"description\": \"S3互換ストレージを使用してノートを同期します\",\n        \"status\": \"接続状態\",\n        \"connected\": \"接続済み\",\n        \"connecting\": \"接続中\",\n        \"disconnected\": \"未接続\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"Access Key IDを入力してください\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"Secret Access Keyを入力してください\",\n        \"region\": \"リージョン\",\n        \"bucket\": \"バケット\",\n        \"bucketPlaceholder\": \"バケット名を入力してください\",\n        \"endpoint\": \"エンドポイント\",\n        \"pathPrefix\": \"パスプレフィックス\",\n        \"pathPrefixPlaceholder\": \"パスプレフィックスを入力してください\",\n        \"pathPrefixDesc\": \"異なるユーザーのファイルを区別するために使用、リポジトリ名に類似\",\n        \"customDomain\": \"カスタムドメイン\",\n        \"testConnection\": \"接続テスト\",\n        \"testing\": \"テスト中\",\n        \"saveConfig\": \"設定を保存\",\n        \"saving\": \"保存中\"\n      },\n      \"webdav\": {\n        \"title\": \"WebDAV 同期\",\n        \"description\": \"WebDAVプロトコルを使用してノートを同期します\",\n        \"status\": \"接続状態\",\n        \"connected\": \"接続済み\",\n        \"connecting\": \"接続中\",\n        \"disconnected\": \"未接続\",\n        \"url\": \"サーバーURL\",\n        \"urlPlaceholder\": \"WebDAV サーバーURLを入力してください\",\n        \"urlDesc\": \"Synology、QNAP、Nextcloud などのWebDAVサービスをサポート\",\n        \"username\": \"ユーザー名\",\n        \"usernamePlaceholder\": \"ユーザー名を入力してください\",\n        \"password\": \"パスワード\",\n        \"passwordPlaceholder\": \"パスワードを入力してください\",\n        \"pathPrefix\": \"パスプレフィックス\",\n        \"pathPrefixPlaceholder\": \"パスプレフィックスを入力してください\",\n        \"pathPrefixDesc\": \"異なるユーザーのファイルを区別するために使用\",\n        \"testConnection\": \"接続テスト\",\n        \"testing\": \"テスト中\",\n        \"saveConfig\": \"設定を保存\",\n        \"saving\": \"保存中\"\n      }\n    },\n    \"imageHosting\": {\n      \"title\": \"画像ホスティング\",\n      \"desc\": \"ここでは、画像の保存と管理のための画像ホスティングサービスを設定できます。\",\n      \"type\": \"プラットフォームを選択\",\n      \"typeDesc\": \"画像ホスティングサービスを選択\",\n      \"customRepoName\": \"カスタムリポジトリ名\",\n      \"customRepoNameDesc\": \"空白の場合はデフォルトのリポジトリ名を使用\",\n      \"isPrimaryBackup\": \"現在の {type} 主要バックアップ方法\",\n      \"setPrimaryBackup\": \"主要バックアップ方法として設定\",\n      \"smms\": {\n        \"token\": {\n          \"desc\": \"SM.MS Token を入力してください。\",\n          \"createToken\": \"Token を作成\"\n        },\n        \"disk\": \"磁盘使用\",\n        \"error\": \"取得失敗、ネットワークや Token の正しさを確認してください。\"\n      },\n      \"picgo\": {\n        \"desc\": \"PicGo サーバー URL\",\n        \"ok\": \"サービスが起動しています。PicGo の設定を確認してください。\",\n        \"error\": \"サービスが起動していません。PicGo（v2.2.0+）を起動してください。\"\n      },\n      \"github\": {\n        \"title\": \"GitHub画像ホスティング\",\n        \"description\": \"GitHubリポジトリを画像ストレージサービスとして使用\",\n        \"repoStatus\": \"リポジトリステータス\",\n        \"repoExists\": \"リポジトリが存在します\",\n        \"repoNotExists\": \"リポジトリが見つかりません\",\n        \"checking\": \"確認中\",\n        \"creating\": \"作成中\",\n        \"manualCreateTitle\": \"手動でリポジトリ作成が必要\",\n        \"manualCreateDesc\": \"以下の手順で画像ホスティングリポジトリを作成してください：\",\n        \"createSteps\": {\n          \"step1\": \"GitHubにアクセスしてアカウントにログイン\",\n          \"step2\": \"右上の「+」ボタンをクリックし、「New repository」を選択\",\n          \"step3\": \"リポジトリ名を設定：\",\n          \"step4\": \"プライベートリポジトリに設定することも可能（推奨）\",\n          \"step5\": \"「Create repository」をクリックして作成完了\",\n          \"step6\": \"作成後、下の「再確認」ボタンをクリック\"\n        },\n        \"createNewRepo\": \"新しいリポジトリを作成\",\n        \"recheckRepo\": \"再確認\",\n        \"recheckingRepo\": \"確認中...\",\n        \"createRepo\": \"リポジトリを作成\",\n        \"creatingRepo\": \"作成中...\"\n      },\n      \"s3\": {\n        \"title\": \"S3オブジェクトストレージ\",\n        \"description\": \"AWS S3またはS3互換オブジェクトストレージサービスを画像ホスティングとして設定\",\n        \"status\": \"接続状態\",\n        \"connected\": \"接続済み\",\n        \"connecting\": \"接続中\",\n        \"disconnected\": \"未接続\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"Access Key ID を入力\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"Secret Access Key を入力\",\n        \"region\": \"リージョン\",\n        \"bucket\": \"バケット\",\n        \"bucketPlaceholder\": \"バケット名を入力\",\n        \"advancedSettings\": \"詳細設定\",\n        \"endpoint\": \"カスタムエンドポイント\",\n        \"endpointDesc\": \"AWS S3 の場合は空白、S3 互換サービスのエンドポイントを入力\",\n        \"customDomain\": \"カスタムドメイン\",\n        \"customDomainDesc\": \"オプション、画像アクセス用のカスタムドメイン\",\n        \"pathPrefix\": \"パスプレフィックス\",\n        \"pathPrefixDesc\": \"オプション、画像保存のパスプレフィックス\",\n        \"save\": \"設定を保存\",\n        \"test\": \"接続テスト\",\n        \"setAsPrimary\": \"メインに設定\",\n        \"error\": \"設定エラー\",\n        \"requiredFields\": \"必須フィールドを入力してください：Access Key ID、Secret Access Key、リージョン、バケット\",\n        \"saveSuccess\": \"設定保存成功\",\n        \"saveSuccessDesc\": \"S3 設定が保存されました\",\n        \"saveError\": \"設定保存失敗\",\n        \"testSuccess\": \"接続テスト成功\",\n        \"testSuccessDesc\": \"S3 接続正常、画像アップロード可能\",\n        \"testFailed\": \"接続テスト失敗\",\n        \"testFailedDesc\": \"設定情報とネットワーク接続を確認してください\",\n        \"testFirstDesc\": \"メイン画像ホスティングに設定する前に接続テストを成功させてください\",\n        \"setPrimarySuccess\": \"設定成功\",\n        \"setPrimarySuccessDesc\": \"S3 がメイン画像ホスティングに設定されました\"\n      }\n    },\n    \"imageMethod\": {\n      \"title\": \"画像認識\",\n      \"desc\": \"ここでは、画像認識に関する設定を配置できます。OCR と VLM の二つの方法をサポートします。\",\n      \"enable\": {\n        \"title\": \"画像認識を有効にする\",\n        \"desc\": \"有効にすると、スクリーンショット記録と画像記録時に自動的に画像認識が行われます。無効にすると、画像認識がスキップされます。\"\n      },\n      \"setPrimary\": \"設定をデフォルト\",\n      \"isPrimary\": \"{type} がデフォルト設定\",\n      \"ocr\": {\n        \"title\": \"OCR\",\n        \"languagePacks\": \"言語パック\",\n        \"checkModels\": \"ここでは、すべてのモデルを検索できます。\",\n        \"modelInstruction\": \"逗号分隔、例如：eng,chi_sim\"\n      },\n      \"vlm\": {\n        \"title\": \"視覚的言語モデル\",\n        \"desc\": \"視覚的言語モデルを使用して画像内容を認識します。\"\n      }\n    },\n    \"backupSync\": {\n      \"title\": \"備用方案\",\n      \"desc\": \"ここでは、他の方法でデータをバックアップすることができます。定期的にバックアップを取ることで、データの安全を確保できます。\",\n      \"localBackup\": {\n        \"tabTitle\": \"ローカルバックアップ\",\n        \"export\": {\n          \"title\": \"バックアップのエクスポート\",\n          \"desc\": \"アプリケーションデータを .zip ファイルにパッケージし、指定した場所に保存します。\",\n          \"button\": \"場所を選択してエクスポート\",\n          \"simpleButton\": \"エクスポート\",\n          \"exporting\": \"エクスポート中...\"\n        },\n        \"import\": {\n          \"title\": \"バックアップのインポート\",\n          \"desc\": \".zip ファイルからアプリケーションデータを復元し、現在のすべてのデータを上書きします。\",\n          \"button\": \"ファイルを選択してインポート\",\n          \"importing\": \"インポート中...\",\n          \"warning\": \"インポート操作により現在のすべてのデータが上書きされます。重要なコンテンツがバックアップされていることを確認してください！\"\n        },\n        \"exportDialog\": {\n          \"title\": \"バックアップファイルの保存場所を選択\"\n        },\n        \"importDialog\": {\n          \"title\": \"インポートするバックアップファイルを選択\"\n        },\n        \"exportSuccess\": \"バックアップのエクスポートが成功しました！\",\n        \"exportError\": \"バックアップのエクスポートに失敗しました\",\n        \"importSuccess\": \"バックアップのインポートが成功しました！変更を適用するためにアプリケーションが再起動されます。\",\n        \"importError\": \"バックアップのインポートに失敗しました\",\n        \"restartConfirm\": \"インポートが完了しました！変更を適用するために今すぐアプリケーションを再起動しますか？\"\n      }\n    },\n    \"template\": {\n      \"title\": \"整理テンプレート\",\n      \"desc\": \"ここでは、カスタム整理テンプレートを作成・管理し、AIがあなたのニーズに応じて記録内容を整理するのを支援できます。\",\n      \"customTemplate\": \"カスタムテンプレート\",\n      \"addTemplate\": \"テンプレートを追加\",\n      \"deleteConfirm\": \"このテンプレートを削除してもよろしいですか？\",\n      \"status\": \"状態\",\n      \"name\": \"名称\",\n      \"content\": \"内容\",\n      \"scope\": \"範囲\",\n      \"selectScope\": \"範囲を選択\",\n      \"addTemplateDesc\": \"カスタムテンプレート名称と内容を入力し、AIがあなたのニーズをよりよく理解できるようにしてください。\",\n      \"editTemplate\": \"編集カスタムテンプレート\",\n      \"noContent\": \"内容がありません\",\n      \"range\": {\n        \"all\": \"すべて\",\n        \"today\": \"今日\",\n        \"week\": \"過去1週間\",\n        \"month\": \"過去1ヶ月\",\n        \"threeMonth\": \"過去3ヶ月\",\n        \"year\": \"過去1年\"\n      }\n    },\n    \"shortcut\": {\n      \"title\": \"ショートカットキー\",\n      \"screenshot\": \"スクリーンショット記録\",\n      \"link\": \"链接記録\",\n      \"textRecord\": \"テキスト記録\",\n      \"windowPin\": \"ウィンドウをピン留め\"\n    },\n    \"theme\": {\n      \"title\": \"外観\",\n      \"appTheme\": \"アプリテーマ\",\n      \"previewTheme\": \"プレビュー用テーマ\",\n      \"codeTheme\": \"コードブロックのハイライトテーマ\",\n      \"selectTheme\": \"テーマを選択\"\n    },\n    \"chat\": {\n      \"title\": \"会話設定\",\n      \"desc\": \"ここでは、要約生成など会話関連の設定を構成できます。\",\n      \"primaryModel\": {\n        \"title\": \"メインモデル\",\n        \"model\": {\n          \"title\": \"メインチャットモデル\",\n          \"desc\": \"日常会話に使用するメインの AI モデルを選択\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"ツールバー設定\",\n        \"chatToolbar\": {\n          \"title\": \"チャットツールバー\",\n          \"desc\": \"チャットツールバーボタンの表示順序と可視性をカスタマイズ\",\n          \"button\": \"設定\",\n          \"modelSelect\": {\n            \"desc\": \"会話に使用する AI モデルを切り替え\"\n          },\n          \"promptSelect\": {\n            \"desc\": \"会話で使用するプリセットプロンプトを選択\"\n          },\n          \"chatLanguage\": {\n            \"desc\": \"会話の言語を設定\"\n          },\n          \"chatLink\": {\n            \"title\": \"タグをリンク\",\n            \"desc\": \"現在のタグのノート内容を会話コンテキストにリンク\"\n          },\n          \"fileLink\": {\n            \"desc\": \"ファイルまたはフォルダーを会話コンテキストにリンク\"\n          },\n          \"mcpButton\": {\n            \"desc\": \"MCP サーバーを選択して接続し、外部ツールを使用\"\n          },\n          \"ragSwitch\": {\n            \"title\": \"ナレッジベース\",\n            \"desc\": \"ベクトルナレッジベース検索を有効にする\"\n          },\n          \"clipboardMonitor\": {\n            \"title\": \"クリップボード監視\",\n            \"desc\": \"クリップボードの内容の変更を自動監視\"\n          },\n          \"newChat\": {\n            \"desc\": \"新しい会話を開始\"\n          },\n          \"clearContext\": {\n            \"desc\": \"会話コンテキストをクリアし、チャット履歴を保持\"\n          },\n          \"clearChat\": {\n            \"desc\": \"すべてのチャット記録を削除\"\n          }\n        }\n      },\n      \"condense\": {\n        \"title\": \"会話の要約\",\n        \"enable\": {\n          \"title\": \"要約を有効にする\",\n          \"desc\": \"長い会話を自動的に圧縮してトークン使用量を節約\"\n        },\n        \"model\": {\n          \"title\": \"要約モデル\",\n          \"desc\": \"要約生成に使用する AI モデルを選択\",\n          \"placeholder\": \"メインモデルを使用\"\n        },\n        \"threshold\": {\n          \"title\": \"トリガーしきい値\",\n          \"desc\": \"AI メッセージがこの数を超えたときに圧縮をチェック\"\n        },\n        \"minToken\": {\n          \"title\": \"最小トークン数\",\n          \"desc\": \"このトークン数を超えるメッセージのみを圧縮\"\n        },\n        \"keepLatest\": {\n          \"title\": \"最新を保持\",\n          \"desc\": \"最新の N 件の AI メッセージを圧縮しない\"\n        },\n        \"maxLength\": {\n          \"title\": \"要約の最大長\",\n          \"desc\": \"生成される要約の最大文字数を制御\"\n        },\n        \"prompt\": {\n          \"title\": \"カスタム要約プロンプト\",\n          \"desc\": \"要約生成に使用するプロンプトテンプレートをカスタマイズ\",\n          \"label\": \"プロンプトテンプレート\",\n          \"placeholder\": \"カスタムプロンプトを入力...\",\n          \"help\": \"元のコンテンツのプレースホルダーとして {content} を使用\",\n          \"save\": \"保存\",\n          \"reset\": \"デフォルトにリセット\"\n        }\n      },\n      \"inspiration\": {\n        \"title\": \"インスピレーションモデル\",\n        \"model\": {\n          \"title\": \"クイックプロンプト生成\",\n          \"desc\": \"会話を開始するためのクイックプロンプト候補を生成します\"\n        }\n      },\n      \"conversationTitle\": {\n        \"title\": \"会话标题\",\n        \"model\": {\n          \"title\": \"标题生成模型\",\n          \"desc\": \"选择用于生成会话标题的 AI 模型\"\n        }\n      }\n    },\n    \"dev\": {\n      \"title\": \"開発者\",\n      \"desc\": \"ここでは、ネットワークプロキシ、データクリーンアップ、設定ファイル管理などの開発者オプションを設定できます。\",\n      \"clearData\": \"データをクリア\",\n      \"clearDataConfirm\": \"本当にデータをクリアしますか？\",\n      \"proxy\": \"ネットワーク問題解決用のプロキシ。設定後はアプリの再起動を推奨します。\",\n      \"proxyPlaceholder\": \"プロキシアドレスを入力してください\",\n      \"proxyTitle\": \"ネットワークプロキシ\",\n      \"clearDataTitle\": \"データをクリア\",\n      \"clearDataDesc\": \"システム設定情報とデータベース（記録を含む）をクリアします。\",\n      \"clearFileTitle\": \"ファイルをクリア\",\n      \"clearFileDesc\": \"画像や記事などのファイルをクリアします。\",\n      \"clearButton\": \"クリア\",\n      \"configFileTitle\": \"設定ファイル管理\",\n      \"configFileDesc\": \"設定ファイルのインポートとエクスポート。インポートすると現在の設定が上書きされ、再起動後に有効になります。\",\n      \"importConfigTitle\": \"設定ファイルをインポート\",\n      \"exportConfigTitle\": \"設定ファイルをエクスポート\",\n      \"importConfigSuccessMobile\": \"設定のダウンロードに成功しました。手動でアプリを再起動してください\",\n      \"exportConfigSuccess\": \"エクスポート成功\",\n      \"importButton\": \"インポート\",\n      \"exportButton\": \"エクスポート\"\n    },\n    \"ai\": {\n      \"title\": \"モデル管理\",\n      \"desc\": \"ここでさまざまなカスタムモデルサービスを追加・管理できます。設定後、整理や会話などAI機能が有効になります。\",\n      \"modelTitle\": \"カスタム名\",\n      \"modelConfigTitle\": \"モデル配置\",\n      \"modelConfigDesc\": \"それぞれの構成はAIモデルに対応しており、テンプレートやカスタムで新しい構成を作成することができます。\",\n      \"providerInfo\": \"プロバイダー情報\",\n      \"providerInfoDesc\": \"この設定はプロバイダーテンプレートに基づいて作成され、名前とURLが事前設定されています。\",\n      \"create\": \"作成\",\n      \"createDesc\": \"空の設定を選択するか、供給元のテンプレートを使用して新しい設定を作成します。\",\n      \"createSection\": {\n        \"title\": \"カスタムモデル設定\",\n        \"descWithoutModels\": \"カスタムAIモデル設定を追加して、より強力なモデルサービスを使用しましょう。\"\n      },\n      \"config\": \"配置单\",\n      \"custom\": \"自定義\",\n      \"addCustomModel\": \"自定義\",\n      \"deleteCustomModel\": \"削除\",\n      \"deleteCustomModelConfirm\": \"この自定義モデル設定を削除してもよろしいですか？\",\n      \"copyConfig\": \"コピー\",\n      \"builtin\": \"内蔵\",\n      \"modelSupport\": \"OpenAIプロトコル対応のAIモデルのみサポート\",\n      \"apiKeyUrl\": \"Create API Key\",\n      \"modelType\": {\n        \"title\": \"モデルタイプ\",\n        \"desc\": \"能力に基づいてAIモデルのタイプを選択します\",\n        \"chat\": \"チャット\",\n        \"image\": \"画像生成\",\n        \"video\": \"動画\",\n        \"audio\": \"音声\",\n        \"embedding\": \"埋め込み\",\n        \"rerank\": \"再ランキング\",\n        \"tts\": \"文本转语音\",\n        \"stt\": \"语音转文本\"\n      },\n      \"modelList\": {\n        \"error\": {\n          \"title\": \"モデルリストの取得に失敗しました\",\n          \"description\": \"API Key またはネットワークを確認してください\"\n        }\n      },\n      \"selectModel\": \"モデルを選択してください\",\n      \"modelProviderDesc\": \"自定義モデルOpenAIプロトコル対応のAIモデルのみサポート。\",\n      \"modelTitleDesc\": \"カスタム名、用于标识 AI 模型，请勿重复。\",\n      \"modelBaseUrlDesc\": \"バージョン番号まで設定すればOKです（例：https://api.openai.com/v1）。サフィックスは自動で追加されます。\",\n      \"modelDesc\": \"一部モデルはリスト取得に対応しています。未対応の場合は手動で設定してください。\",\n      \"temperatureDesc\": \"サンプリング温度は0から2の間で設定します。高い値（例:0.8）は出力をよりランダムに、低い値（例:0.2）はより決定的にします。通常、この値かtop_pのみを調整してください。\",\n      \"topPDesc\": \"top_p（核サンプリング）は温度の代替手法です。top_p=0.1なら上位10%確率のトークンのみ考慮します。通常、この値かtemperatureのみを調整してください。\",\n      \"customHeaders\": \"カスタムヘッダー\",\n      \"customHeadersDesc\": \"キーと値のペアでカスタムHTTPヘッダーを追加します。\",\n      \"connectionSuccess\": \"AI接続テストが成功しました\",\n      \"headerKey\": \"キー\",\n      \"headerValue\": \"値\",\n      \"addHeader\": \"ヘッダーを追加\",\n      \"voice\": \"音声タイプ\",\n      \"voiceDesc\": \"音声モデルで使用する音声タイプを指定します（例：'alloy'、'echo'、'fable'など）。\",\n      \"voicePlaceholder\": \"音声タイプを入力してください（例：alloy）\",\n      \"defaultModels\": {\n        \"title\": \"デフォルト無料モデル\",\n        \"desc\": \"NoteGenはユーザーに無料のAIモデルサービスを提供し、設定なしで基本機能を使用できます。\",\n        \"chatModel\": {\n          \"name\": \"Qwen/Qwen3-8B\",\n          \"type\": \"チャットモデル\",\n          \"desc\": \"日常会話やテキスト生成に適用\"\n        },\n        \"embeddingModel\": {\n          \"name\": \"BAAI/bge-m3\",\n          \"type\": \"埋め込みモデル\",\n          \"desc\": \"テキストベクトル化や意味検索に使用\"\n        },\n        \"visionModel\": {\n          \"name\": \"OpenGVLab/InternVL2-8B\",\n          \"type\": \"ビジョンモデル\",\n          \"desc\": \"画像理解とビジュアルQ&Aをサポート\"\n        },\n        \"completionModel\": {\n          \"name\": \"高速補完\",\n          \"type\": \"補完モデル\",\n          \"desc\": \"Markdownエディタ用のAIインライン補完、GitHub Copilotに類似、素早く続きの内容を生成\"\n        },\n        \"poweredBy\": \"SiliconFlowによる技術サポート\"\n      },\n      \"connectionFailed\": \"接続に失敗しました\",\n      \"enableStream\": \"ストリーミングレスポンス\",\n      \"enableStreamDesc\": \"ストリーミングレスポンスを有効にすると、生成中のコンテンツをリアルタイムで表示できますが、一部のモデルではこの機能がサポートされていない場合があります。\",\n      \"selectConfig\": \"設定を選択してください\",\n      \"models\": \"モデルリスト\",\n      \"modelsDesc\": \"現在の設定のすべてのモデルをここで管理します。各モデルは異なるタイプとパラメータを持つことができます。\",\n      \"addModel\": \"モデルを追加\",\n      \"newModel\": \"新しいモデル\",\n      \"checkConnection\": \"接続をテスト\",\n      \"model\": \"モデル\"\n    },\n    \"ocr\": {\n      \"title\": \"OCR\",\n      \"languagePacks\": \"言語パック\",\n      \"checkModels\": \"在此查询全部模型\",\n      \"modelInstruction\": \"以逗号分隔，例如：eng,chi_sim\"\n    },\n    \"file\": {\n      \"title\": \"ファイル管理\",\n      \"desc\": \"ここで同期リポジトリを設定し、執筆中のファイルをリポジトリと同期できます。画像ホスティングもサポートしています。\",\n      \"workspace\": {\n        \"title\": \"ワークスペース設定\",\n        \"desc\": \"アプリケーションのワークスペースディレクトリを設定します。ファイルはこのディレクトリに保存されます。\",\n        \"current\": \"現在のワークスペースパス\",\n        \"defaultPath\": \"デフォルトワークスペース\",\n        \"default\": \"現在デフォルトのワークスペースパスを使用しています。\",\n        \"custom\": \"現在カスタムワークスペースパスを使用しています。\",\n        \"select\": \"ワークスペースディレクトリを選択\",\n        \"reset\": \"デフォルトパスにリセット\",\n        \"history\": \"履歴パス\",\n        \"selectFromHistory\": \"履歴からワークスペースを選択\",\n        \"clearHistory\": \"履歴をクリア\",\n        \"actions\": \"アクション\",\n        \"searchPlaceholder\": \"ワークスペースパスを検索...\",\n        \"noResults\": \"結果が見つかりません\"\n      },\n      \"info\": {\n        \"title\": \"ワークスペースの説明\",\n        \"desc\": \"ワークスペースを変更した後、完全に反映させるにはアプリケーションを再起動する必要があります。新しいワークスペース内のファイルは再起動後に表示されます。\"\n      },\n      \"toast\": {\n        \"updated\": \"ワークスペースが更新されました\",\n        \"updatedDesc\": \"ワークスペースが次のパスに設定されました: {path}\",\n        \"reset\": \"ワークスペースがリセットされました\",\n        \"resetDesc\": \"デフォルトのワークスペースに戻りました\",\n        \"error\": \"ワークスペースの選択に失敗しました\",\n        \"errorDesc\": \"ワークスペースディレクトリを選択できません。再試行してください。\",\n        \"resetError\": \"ワークスペースのリセットに失敗しました\",\n        \"resetErrorDesc\": \"デフォルトのワークスペースにリセットできません。再試行してください。\"\n      },\n      \"assets\": {\n        \"title\": \"アセット\",\n        \"desc\": \"執筆中のリソース（例：画像、ビデオ、ファイル等）の保存パスを設定します。現在編集中の markdown ファイルと同じレベルに保存されます。\",\n        \"select\": \"執筆中のリソースの保存パスを設定してください\"\n      }\n    },\n    \"shortcuts\": {\n      \"title\": \"ショートカット\",\n      \"desc\": \"ここで、NoteGen をより効率的に使用するためのショートカットを設定できます。\",\n      \"resetDefaults\": \"リセットします\",\n      \"clear\": \"クリア\",\n      \"noShortcut\": \"未設定\",\n      \"shortcuts\": {\n        \"openWindow\": {\n          \"title\": \"ウィンドウを開く/隠す\",\n          \"desc\": \"主ウィンドウを開く/隠す。\"\n        },\n        \"quickRecordText\": {\n          \"title\": \"テキストを素早く記録します\",\n          \"desc\": \"主ウィンドウを開き、テキスト記録を表示します。\"\n        }\n      }\n    },\n    \"skills\": {\n      \"title\": \"Skills\",\n      \"desc\": \"Skills 是可重用的 AI 能力包，让 AI 助手能够根据任务自动应用特定的行为模式。\",\n      \"enable\": \"启用 Skills 功能\",\n      \"enableDesc\": \"启用后，AI 可以使用已配置的 Skills\",\n      \"autoMatch\": \"自动匹配 Skills\",\n      \"autoMatchDesc\": \"根据用户输入自动选择合适的 Skills\",\n      \"project\": \"工作区 Skills\",\n      \"global\": \"全局 Skills\",\n      \"globalPath\": \"全局 Skills 存储位置\",\n      \"openInFileManager\": \"在文件管理器中打开\",\n      \"createSkill\": \"创建 Skill\",\n      \"editSkill\": \"编辑 Skill\",\n      \"deleteSkill\": \"删除 Skill\",\n      \"exportSkill\": \"导出 Skill\",\n      \"importSkill\": \"导入 Skill\",\n      \"selectSkillZip\": \"选择 Skill zip 文件\",\n      \"importSuccess\": \"导入成功\",\n      \"importError\": \"导入失败\",\n      \"imported\": \"已导入\",\n      \"importing\": \"导入中...\",\n      \"skillName\": \"Skill 名称\",\n      \"skillDescription\": \"描述\",\n      \"skillVersion\": \"版本\",\n      \"skillAuthor\": \"作者\",\n      \"allowedTools\": \"允许使用的工具\",\n      \"userInvocable\": \"在斜杠菜单显示\",\n      \"instructions\": \"指令内容\",\n      \"instructionsPlaceholder\": \"输入给 AI 的详细指令...\",\n      \"importHelp\": \"支持导入 zip 格式的 Skill，zip 文件需包含 SKILL.md 文件。\",\n      \"metadata\": \"元数据\",\n      \"content\": \"指令内容\",\n      \"noSkills\": \"还没有 Skills\",\n      \"noSkillsDesc\": \"创建或导入 Skills 以开始使用\",\n      \"noSkillsGlobal\": \"还没有全局 Skills\",\n      \"noSkillsGlobalDesc\": \"创建或导入 Skills 以在所有项目中使用\",\n      \"emptyWorkspace\": \"工作区中没有 Skills\",\n      \"emptyWorkspaceDesc\": \"在 skills 文件夹中创建 SKILL.md 文件来添加 Skill\",\n      \"basicSettings\": \"基础设置\",\n      \"installedGlobalSkills\": \"已安装的全局 Skills\",\n      \"nameRequired\": \"请输入 Skill 名称\",\n      \"descriptionRequired\": \"请输入描述\",\n      \"namePlaceholder\": \"note-organizer\",\n      \"versionPlaceholder\": \"1.0.0\",\n      \"descriptionPlaceholder\": \"自动整理和优化笔记结构...\",\n      \"authorPlaceholder\": \"Your Name\",\n      \"descriptionHelp\": \"用于 AI 匹配，描述此 Skill 的功能和适用场景\",\n      \"allowedToolsHelp\": \"这些工具使用时不需要用户确认\",\n      \"userInvocableHelp\": \"用户可以通过 /skill-name 手动触发\",\n      \"instructionsHelp\": \"给 AI 的详细指令，支持 Markdown 格式\",\n      \"deleteSkillTitle\": \"删除 Skill\",\n      \"deleteSkillDesc\": \"确定要删除这个 Skill 吗？此操作无法撤销。\",\n      \"skillDeleted\": \"Skill 删除成功\"\n    },\n    \"audio\": {\n      \"title\": \"语音设置\",\n      \"desc\": \"在这里，你可以配置语音相关设置，包括文本转语音（朗读）和语音转文本（录音识别）功能。\",\n      \"mode\": {\n        \"title\": \"モード\",\n        \"auto\": \"自動（推奨）\",\n        \"local\": \"ローカルのみ\",\n        \"model\": \"モデルのみ\"\n      },\n      \"tts\": {\n        \"title\": \"文本转语音（TTS）\",\n        \"desc\": \"配置朗读功能，为聊天内容提供语音播放。\",\n        \"modeDesc\": \"既定ではブラウザとシステム音声を優先し、必要な場合のみモデルを使って体験を強化します。\",\n        \"model\": {\n          \"title\": \"朗读模型\",\n          \"desc\": \"任意です。自動モードの強化やモデル専用モードで利用できます。\"\n        },\n        \"speed\": {\n          \"title\": \"语速\",\n          \"desc\": \"调整语音播放的速度，范围从0.5倍到2倍速度，默认为1倍正常速度。\"\n        }\n      },\n      \"stt\": {\n        \"title\": \"语音转文本（STT）\",\n        \"desc\": \"配置录音识别功能，将语音转换为文字记录。\",\n        \"modeDesc\": \"既定ではブラウザ標準認識を優先し、ローカル利用不可時のみモデルへフォールバックします。\",\n        \"model\": {\n          \"title\": \"识别模型\",\n          \"desc\": \"任意です。自動フォールバック用、またはモデル専用モードで使用できます。\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"読み上げ\",\n      \"desc\": \"読み上げ方法を設定します。既定ではシステム音声を優先し、モデル音声は強化用として利用します。\",\n      \"options\": {\n        \"mode\": {\n          \"title\": \"モード\",\n          \"desc\": \"自動モードではシステム音声を優先し、ローカル音声が使えない場合のみモデルを試します。\",\n          \"auto\": \"自動（推奨）\",\n          \"local\": \"ローカルのみ\",\n          \"model\": \"モデルのみ\"\n        },\n        \"audioModel\": {\n          \"title\": \"読み上げモデル\",\n          \"desc\": \"任意です。自動モードの強化やモデル専用モードで利用できます。\"\n        },\n        \"speed\": {\n          \"title\": \"速度\",\n          \"desc\": \"読み上げ速度を0.5倍から2倍まで調整できます。既定値は1倍です。\"\n        }\n      }\n    }\n  },\n  \"record\": {\n    \"trash\": {\n      \"title\": \"ゴミ箱を空にする\",\n      \"confirm\": \"本当にデータをクリアしますか？\",\n      \"records\": \"{count}件の記録が復元可能です\",\n      \"empty\": \"空にする\",\n      \"close\": \"ゴミ箱を閉じる\"\n    },\n    \"queue\": {\n      \"ocr\": \"OCR 認識\",\n      \"ai\": \"AI 認識\",\n      \"upload\": \"画像ホストへアップロード\",\n      \"jsdelivr\": \"jsdelivrキャッシュ通知\",\n      \"save\": \"保存\",\n      \"recording\": \"記録中...\",\n      \"recorded\": \"記録済み\",\n      \"record\": \"記録\",\n      \"detected\": \"検出済み\"\n    },\n    \"mark\": {\n      \"empty\": \"記録はまだありません\",\n      \"loading\": \"読み込み中...\",\n      \"type\": {\n        \"scan\": \"スキャン\",\n        \"image\": \"イラスト\",\n        \"screenshot\": \"スクリーンショット\",\n        \"text\": \"テキスト\",\n        \"file\": \"ファイル\",\n        \"link\": \"リンク\",\n        \"todo\": \"Todo\",\n        \"pdf\": \"PDF\",\n        \"upload\": \"アップロード\",\n        \"download\": \"ダウンロード\",\n        \"uploadTo\": \"ローカルから {provider} へ同期\",\n        \"downloadFrom\": \"{provider} からローカルへ同期\",\n        \"recording\": \"录音\"\n      },\n      \"uploadSuccess\": \"記録アップロード成功\",\n      \"downloadSuccess\": \"記録ダウンロード成功\",\n      \"desc\": \"説明\",\n      \"content\": \"内容\",\n      \"createdAt\": \"作成日時\",\n      \"progress\": {\n        \"cacheImage\": \"画像をキャッシュ\",\n        \"ocr\": \"OCR 認識\",\n        \"aiAnalysis\": \"AI 認識\",\n        \"uploadImage\": \"画像ホストへアップロード\",\n        \"jsdelivrCache\": \"jsdelivr キャッシュ通知\",\n        \"cacheFile\": \"ファイルをキャッシュ\",\n        \"cacheScreenshot\": \"スクリーンショットをキャッシュ\",\n        \"textAnalysis\": \"テキスト分析\",\n        \"save\": \"保存\",\n        \"saveImage\": \"画像を保存\"\n      },\n      \"imageGallery\": {\n        \"expand\": \"展開\",\n        \"collapse\": \"折りたたむ\"\n      },\n      \"text\": {\n        \"title\": \"テキスト記録\",\n        \"description\": \"テキストを記録します。ノート整理時に適切な位置に挿入されます。\",\n        \"characterCount\": \"{count} 文字\",\n        \"save\": \"記録\",\n        \"autoReadClipboard\": \"クリップボードのテキストを自動読み取り\"\n      },\n      \"link\": {\n        \"title\": \"リンク記録\",\n        \"description\": \"ウェブページのリンクを入力すると、システムは自動的にページの内容を取得して保存します\",\n        \"save\": \"保存\",\n        \"autoReadClipboard\": \"クリップボードのリンクを自動読み取り\"\n      },\n      \"todo\": {\n        \"title\": \"Todo記録\",\n        \"description\": \"Todoアイテムを作成してタスクを管理\",\n        \"titlePlaceholder\": \"Todoのタイトルを入力...\",\n        \"descriptionPlaceholder\": \"詳細説明を入力（オプション）\",\n        \"priority\": \"優先度\",\n        \"priorityLow\": \"低\",\n        \"priorityMedium\": \"中\",\n        \"priorityHigh\": \"高\",\n        \"dateRange\": \"日付範囲\",\n        \"dateRangePlaceholder\": \"日付範囲を選択\",\n        \"dueDate\": \"期限\",\n        \"dueDatePlaceholder\": \"日付を選択\",\n        \"save\": \"Todo作成\",\n        \"saveEdit\": \"保存\",\n        \"edit\": \"Todo編集\",\n        \"editDescription\": \"Todoアイテムの詳細を変更\",\n        \"cancel\": \"キャンセル\",\n        \"selectTag\": \"タグを選択\",\n        \"completed\": \"完了\",\n        \"uncompleted\": \"未完了\"\n      },\n      \"clipboard\": {\n        \"detectedImage\": \"クリップボードの画像を検出しました\",\n        \"detectedText\": \"クリップボードのテキストを検出しました\"\n      },\n      \"tag\": {\n        \"searchPlaceholder\": \"タグを作成または検索...\",\n        \"noResults\": \"関連タグが見つかりません\",\n        \"quickAdd\": \"クイック作成\",\n        \"pinned\": \"ピン留め\",\n        \"others\": \"その他\",\n        \"rename\": \"リネーム\",\n        \"delete\": \"削除\",\n        \"pin\": \"ピン留め\",\n        \"unpin\": \"キャンセルピン留め\",\n        \"newTag\": \"新しいタグ\",\n        \"newTagPlaceholder\": \"タグ名を入力...\",\n        \"add\": \"追加\"\n      },\n      \"mark\": {\n        \"empty\": \"記録がありません\",\n        \"emptyHint\": \"上部のツールバーを使用して最初の記録を作成しましょう！\",\n        \"type\": {\n          \"text\": \"テキスト\"\n        },\n        \"chat\": {\n          \"modeSelect\": {\n            \"chat\": \"チャット\",\n            \"agent\": \"エージェント\"\n          },\n          \"agent\": {\n            \"running\": \"エージェント実行中\",\n            \"thinking\": \"思考中\",\n            \"acting\": \"実行中\",\n            \"observation\": \"観察結果\",\n            \"toolCalls\": \"ツール呼び出し\",\n            \"thought\": \"思考\",\n            \"action\": \"行動\",\n            \"confirmation\": {\n              \"title\": \"操作の確認\",\n              \"description\": \"エージェントが以下の操作を実行しようとしています。続行するには確認してください。\",\n              \"tool\": \"ツール\",\n              \"parameters\": \"パラメータ\",\n              \"cancel\": \"キャンセル\",\n              \"confirm\": \"確認\",\n              \"confirmed\": \"確認済み\",\n              \"cancelled\": \"キャンセル済み\"\n            }\n          },\n          \"placeholder\": {\n            \"default\": \"質問したり記録を文章に整理したりできます...\",\n            \"noApiKey\": \"API Keyが設定されていないため、AI対話機能を使用できません...\",\n            \"on\": \"AI提案オン\",\n            \"off\": \"AI提案オフ\"\n          },\n          \"header\": {\n            \"configApiKey\": \"API KEYを設定\",\n            \"clearChat\": \"対話をクリア\",\n            \"configPrompt\": \"マスクを設定\",\n            \"selectPrompt\": \"マスクを選択\"\n          },\n          \"clipboard\": {\n            \"image\": {\n              \"detected\": \"クリップボードに画像があります：\",\n              \"recording\": \"記録中\",\n              \"recorded\": \"記録済み\",\n              \"record\": \"記録\"\n            },\n            \"text\": {\n              \"detected\": \"クリップボードにテキストがあります：\",\n              \"recorded\": \"記録済み\",\n              \"record\": \"記録\"\n            }\n          },\n          \"messageControl\": {\n            \"words\": \"文字\",\n            \"summary\": \"要約\"\n          },\n          \"mcp\": {\n            \"maxIterationsReached\": \"最大ツール呼び出し回数に達しました\",\n            \"toolCall\": \"MCP サーバー\",\n            \"params\": \"パラメータ\",\n            \"result\": \"結果\",\n            \"copy\": \"コピー\",\n            \"paramsCopied\": \"パラメータをコピーしました\",\n            \"resultCopied\": \"結果をコピーしました\",\n            \"calling\": \"呼び出し中\",\n            \"success\": \"完了\",\n            \"error\": \"失敗\"\n          },\n          \"empty\": {\n            \"title\": \"AI会話を開始\",\n            \"subtitle\": \"ChatまたはAgentモードでAIと対話\",\n            \"currentModel\": \"現在のモデル\",\n            \"currentPrompt\": \"現在のPrompt\",\n            \"currentMode\": \"会話モード\",\n            \"noModel\": \"モデルが設定されていません\",\n            \"noPrompt\": \"Promptが設定されていません\",\n            \"configureModel\": \"モデルを設定\",\n            \"recentConversations\": \"最近の会話\",\n            \"deleteConversation\": \"会話を削除\",\n            \"conversationHistory\": \"会話履歴\",\n            \"viewMore\": \"もっと見る\",\n            \"messages\": \"件のメッセージ\",\n            \"searchPlaceholder\": \"会話を検索...\",\n            \"noMatchingConversations\": \"一致する会話が見つかりませんでした\",\n            \"noConversationHistory\": \"会話履歴はありません\",\n            \"quickPrompts\": {\n              \"title\": \"クイックスタート\",\n              \"writeNote\": \"ノートを書くのを手伝って\",\n              \"summarize\": \"この内容を要約して\",\n              \"brainstorm\": \"アイデアを出し合って\",\n              \"explain\": \"この概念を説明して\"\n            },\n            \"modeHint\": \"入力ボックスの左側の\",\n            \"modeHintSuffix\": \"ボタンをクリックして会話モードを切り替え\"\n          },\n          \"content\": {\n            \"organize\": \"あなたの記録を文章に整理：\"\n          },\n          \"note\": {\n            \"writing\": \"執筆\",\n            \"convert\": \"文章に変換\",\n            \"description\": \"現在のノートはAIによって生成されており、編集できません。現在のノートを記事に変換（ローカルファイルの生成）して、執筆ページで二次創作を行うことができます。\",\n            \"filename\": \"ファイル名\",\n            \"selectFolder\": \"フォルダを選択\",\n            \"rootDirectory\": \"ルートディレクトリ\",\n            \"deleteTag\": \"現在のタグ、記録、ノートを削除（ゴミ箱から復元可能）\",\n            \"warning\": \"変換後、執筆ページに移動します。\",\n            \"convert_button\": \"変換\"\n          },\n          \"mark\": {\n            \"recorded\": \"已記録\",\n            \"record\": \"記録\"\n          },\n          \"send\": \"送信\"\n        },\n        \"text\": {\n          \"title\": \"記録テキスト\",\n          \"description\": \"テキストを記録します。ノート整理時に適切な位置に挿入されます。\",\n          \"characterCount\": \"{count} 文字\",\n          \"save\": \"記録\"\n        },\n        \"clipboard\": {\n          \"detectedImage\": \"クリップボードの画像を検出しました\",\n          \"detectedText\": \"クリップボードのテキストを検出しました\"\n        },\n        \"tag\": {\n          \"searchPlaceholder\": \"タグを作成または検索...\",\n          \"noResults\": \"関連タグが見つかりません\",\n          \"quickAdd\": \"クイック作成\",\n          \"pinned\": \"ピン留め\",\n          \"others\": \"その他\",\n          \"rename\": \"リネーム\",\n          \"delete\": \"削除\",\n          \"pin\": \"ピン留め\",\n          \"unpin\": \"キャンセルピン留め\"\n        },\n        \"progress\": {\n          \"cacheImage\": \"画像をキャッシュ\",\n          \"ocr\": \"OCR 認識\",\n          \"aiAnalysis\": \"AI 内容認識\",\n          \"uploadImage\": \"画像ホストへアップロード\",\n          \"jsdelivrCache\": \"jsdelivr キャッシュ通知\",\n          \"cacheFile\": \"ファイルをキャッシュ\",\n          \"cacheScreenshot\": \"スクリーンショットをキャッシュ\",\n          \"textAnalysis\": \"テキスト分析\",\n          \"save\": \"保存\",\n          \"saveImage\": \"画像を保存\"\n        }\n      },\n      \"toolbar\": {\n        \"search\": \"検索\",\n        \"trash\": \"ゴミ箱\",\n        \"restore\": \"復元\",\n        \"delete\": \"削除\",\n        \"deleteConfirm\": \"本当に削除しますか？\",\n        \"moveTag\": \"タグに移動\",\n        \"convertTo\": \"{type}に変換\",\n        \"copyLink\": \"リンクをコピー\",\n        \"copied\": \"クリップボードにコピーしました！\",\n        \"regenerateDesc\": \"説明を再生成\",\n        \"viewFolder\": \"フォルダで表示\",\n        \"viewFile\": \"元ファイルを表示\",\n        \"deleteForever\": \"完全に削除\",\n        \"multiSelect\": \"複数選択\",\n        \"exitMultiSelect\": \"複数選択を終了\",\n        \"selectAll\": \"すべて選択\",\n        \"deselectAll\": \"すべて選択解除\",\n        \"selectedCount\": \"{count}項目を選択中\",\n        \"moveSelectedTags\": \"選択した{count}項目を移動\",\n        \"deleteSelected\": \"選択した{count}項目を削除\",\n        \"deleteSelectedForever\": \"選択した{count}項目を完全削除\",\n        \"organizeNotes\": \"ノート整理\",\n        \"organizeSuccess\": \"ノート整理成功：{title}\",\n        \"organizeError\": \"ノート整理失敗\",\n        \"currentTag\": \"現在のタグ\",\n        \"text\": \"テキスト記録\",\n        \"recording\": \"音声記録\",\n        \"scan\": \"画像スキャン\",\n        \"image\": \"画像アップロード\",\n        \"link\": \"リンク記録\",\n        \"file\": \"ファイルアップロード\",\n        \"todo\": \"Todo記録\",\n        \"closeTrash\": \"ゴミ箱を閉じる\"\n      },\n      \"list\": {\n        \"title\": \"記録\"\n      },\n      \"note\": {\n        \"organizeAs\": \"整理先\",\n        \"template\": \"テンプレート\",\n        \"setting\": \"設定\",\n        \"confirm\": \"确认\",\n        \"cancel\": \"取消\",\n        \"removeThinking\": \"移除思考过程\",\n        \"stop\": \"停止\"\n      }\n    },\n    \"chat\": {\n      \"empty\": {\n        \"features\": [\n          {\n            \"chat\": \"AIボットと対話する\"\n          },\n          {\n            \"linked\": \"あなたの記録に関連付けられています\"\n          },\n          {\n            \"clipboard\": \"クリップボードの記録を識別\"\n          },\n          {\n            \"organize\": \"あなたの記録をノートに整理します\"\n          }\n        ],\n        \"title\": \"AIとの会話を開始\",\n        \"subtitle\": \"チャットまたはエージェントモードでAIと対話\",\n        \"currentModel\": \"現在のモデル\",\n        \"currentPrompt\": \"現在のプロンプト\",\n        \"currentMode\": \"会話モード\",\n        \"noModel\": \"モデルが設定されていません\",\n        \"noPrompt\": \"プロンプトが設定されていません\",\n        \"configureModel\": \"モデルを設定\",\n        \"recentConversations\": \"最近の会話\",\n        \"deleteConversation\": \"会話を削除\",\n        \"conversationHistory\": \"会話履歴\",\n        \"viewMore\": \"もっと見る\",\n        \"messages\": \"件のメッセージ\",\n        \"searchPlaceholder\": \"会話を検索...\",\n        \"noMatchingConversations\": \"一致する会話が見つかりませんでした\",\n        \"noConversationHistory\": \"まだ会話履歴がありません\",\n        \"quickPrompts\": {\n          \"title\": \"快速开始\",\n          \"writeNote\": \"帮我写一篇笔记\",\n          \"summarize\": \"帮我总结这段内容\",\n          \"brainstorm\": \"帮我头脑风暴一些想法\",\n          \"explain\": \"帮我解释这个概念\"\n        }\n      },\n      \"newChat\": \"新しいタグで対話を開始\",\n      \"removeChat\": \"現在のタグの対話を削除\",\n      \"confirmNew\": \"新しいタグを作成\",\n      \"confirmNewDescription\": \"新しいタグを作成して対話を始めますか？\",\n      \"confirmRemove\": \"タグを削除\",\n      \"confirmRemoveDescription\": \"このタグを削除すると、内部の記録も削除されます。もう一度確認してください。\",\n      \"input\": {\n        \"organize\": \"整理\",\n        \"chat\": \"チャット\",\n        \"placeholder\": {\n          \"default\": \"メッセージを入力...\",\n          \"noApiKey\": \"API Keyが設定されていないため、AIチャットを使用できません...\",\n          \"on\": \"AIサジェスト有効\",\n          \"off\": \"AIサジェスト無効\",\n          \"noPrimaryModel\": \"メインモデルが設定されていないため、AIチャットを使用できません...\"\n        },\n        \"translate\": {\n          \"tooltip\": \"翻訳\",\n          \"translating\": \"翻訳中...\",\n          \"showOriginal\": \"原文表示\",\n          \"alreadyTranslated\": \"翻訳済み\"\n        },\n        \"clipboardMonitor\": {\n          \"enable\": \"クリップボード監視(有効)\",\n          \"disable\": \"クリップボード監視(無効)\"\n        },\n        \"mcp\": {\n          \"tooltip\": \"MCP サーバー\"\n        },\n        \"send\": \"送信\",\n        \"stop\": \"停止\",\n        \"terminate\": \"終了\",\n        \"tagLink\": {\n          \"on\": \"タグとリンク済み\",\n          \"off\": \"タグとリンクなし\"\n        },\n        \"modelSelect\": {\n          \"tooltip\": \"AIモデルを選択\",\n          \"placeholder\": \"AIモデルを検索\",\n          \"noModel\": \"モデルが見つかりません\"\n        },\n        \"promptSelect\": {\n          \"tooltip\": \"プロンプトを選択\",\n          \"placeholder\": \"プロンプトを検索\"\n        },\n        \"clearChat\": \"会話をクリア\",\n        \"clearContext\": {\n          \"tooltip\": \"コンテキストをクリア\"\n        },\n        \"chatLanguage\": {\n          \"tooltip\": \"会話言語を選択\",\n          \"placeholder\": \"言語を検索\"\n        },\n        \"rag\": {\n          \"notSupported\": \"ベクトルモデルが利用できません\",\n          \"enabled\": \"知識ベース検索有効\",\n          \"disabled\": \"知識ベース検索無効\"\n        },\n        \"modeSelect\": {\n          \"tooltip\": \"入力モードを選択\",\n          \"chat\": \"チャットモード\",\n          \"gen\": \"整理モード\",\n          \"translate\": \"翻訳モード\"\n        },\n        \"chatModeSelect\": {\n          \"chatDescription\": \"クイック会話、分析優先\",\n          \"agentDescription\": \"スマートアシスタント、アクション実行可能\"\n        },\n        \"attachImage\": \"画像を添付\",\n        \"agent\": {\n          \"running\": \"エージェント実行中\",\n          \"thinking\": \"思考中\",\n          \"analyzingRequest\": \"Agent がリクエストを分析しています...\",\n          \"acting\": \"実行中\",\n          \"observation\": \"観察結果\",\n          \"toolCalls\": \"ツール呼び出し\",\n          \"autoFinal\": {\n            \"createNote\": \"ノート「{name}」を作成しました。\",\n            \"createFile\": \"ファイル「{name}」を作成しました。\"\n          },\n          \"thought\": \"思考\",\n          \"action\": \"行動\",\n          \"confirmation\": {\n            \"title\": \"操作の確認\",\n            \"description\": \"エージェントが以下の操作を実行しようとしています。続行するには確認してください。\",\n            \"tool\": \"ツール\",\n            \"parameters\": \"パラメータ\",\n            \"cancel\": \"キャンセル\",\n            \"confirm\": \"確認\",\n            \"confirmed\": \"確認済み\",\n            \"cancelled\": \"キャンセル済み\"\n          }\n        },\n        \"imageSelector\": {\n          \"title\": \"画像を選択\",\n          \"local\": \"ローカルファイル\",\n          \"records\": \"記録から選択\",\n          \"selectFiles\": \"ローカル画像を選択\",\n          \"noRecords\": \"利用可能な画像記録がありません\",\n          \"cancel\": \"キャンセル\",\n          \"confirm\": \"確認\"\n        },\n        \"fileLink\": {\n          \"tooltip\": \"ファイルをリンク\",\n          \"selectFile\": \"ファイルを選択\",\n          \"linkedFile\": \"リンクされたファイル\",\n          \"searchPlaceholder\": \"ファイルを検索...\",\n          \"noFiles\": \"ファイルが見つかりません\",\n          \"loading\": \"読み込み中...\"\n        },\n        \"stopped\": \"对话已终止\",\n        \"newChat\": \"开始新对话\"\n      },\n      \"header\": {\n        \"configApiKey\": \"API KEY の設定\",\n        \"clearChat\": \"チャットをクリア\",\n        \"configPrompt\": \"マスクの設定\",\n        \"selectPrompt\": \"マスクを選択\",\n        \"noModel\": \"AI モデルが選択されていません\"\n      },\n      \"clipboard\": {\n        \"image\": {\n          \"detected\": \"クリップボードに画像が存在します：\",\n          \"recording\": \"記録中\",\n          \"recorded\": \"記録済み\",\n          \"record\": \"記録\"\n        },\n        \"text\": {\n          \"detected\": \"クリップボードにテキストが存在します：\",\n          \"recorded\": \"記録済み\",\n          \"record\": \"記録\"\n        }\n      },\n      \"messageControl\": {\n        \"words\": \"字\",\n        \"summary\": \"要約\",\n        \"readAloud\": \"音声読み上げ\",\n        \"playing\": \"再生中\",\n        \"loading\": \"準備中\",\n        \"stop\": \"停止\",\n        \"copy\": \"コピー\",\n        \"copied\": \"コピーしました\"\n      },\n      \"ragSources\": {\n        \"label\": \"ナレッジベースで {count} 件のメモを検索\",\n        \"openFile\": \"ファイルを開く\"\n      },\n      \"preview\": {\n        \"close\": \"閉じる\",\n        \"copy\": \"コピー\",\n        \"copied\": \"コピーしました！\"\n      },\n      \"control\": {\n        \"edit\": \"編集\",\n        \"save\": \"保存\",\n        \"cancel\": \"キャンセル\",\n        \"delete\": \"削除\",\n        \"deleteConfirm\": \"本当に削除しますか？\"\n      },\n      \"content\": {\n        \"organize\": \"あなたの記録を記事に整理：\"\n      },\n      \"quote\": {\n        \"lineSingle\": \"{fileName} の {line} 行目から引用\",\n        \"lineRange\": \"{fileName} の {startLine}-{endLine} 行目から引用\",\n        \"noLine\": \"{fileName} から引用\"\n      },\n      \"note\": {\n        \"organize\": \"整理\",\n        \"writing\": \"執筆\",\n        \"convert\": \"記事に変換\",\n        \"description\": \"現在のノートはAIによって生成されており、編集できません。現在のノートを記事に変換（ローカルファイルを生成）して、執筆ページで二次創作を行うことができます。\",\n        \"filename\": \"ファイル名\",\n        \"selectFolder\": \"フォルダを選択\",\n        \"rootDirectory\": \"ルートディレクトリ\",\n        \"deleteTag\": \"現在のタグ、記録、ノートを削除（ゴミ箱から復元可能）\",\n        \"warning\": \"変換後、執筆ページに移動します。\",\n        \"convert_button\": \"変換\",\n        \"organizeAs\": \"記録を整理します...\",\n        \"templateContent\": \"テンプレート内容\",\n        \"recordRange\": \"記録範囲を選択\",\n        \"filterThinkingContent\": \"思考の記録を削除します\",\n        \"startOrganize\": \"開始整理\",\n        \"manageTemplate\": \"管理テンプレート\",\n        \"cancel\": \"キャンセル\",\n        \"stop\": \"停止\"\n      },\n      \"mark\": {\n        \"recorded\": \"記録済み\",\n        \"record\": \"記録\"\n      },\n      \"condensing\": \"正在压缩上下文...\",\n      \"condensed\": {\n        \"message\": \"已压缩 {count} 条历史消息\"\n      }\n    },\n    \"tag\": {\n      \"add\": \"添加标签\",\n      \"edit\": \"編集タグ\",\n      \"delete\": \"削除タグ\",\n      \"deleteConfirm\": \"本当に削除しますか？\",\n      \"placeholder\": \"入力するタグ名\"\n    }\n  },\n  \"image\": {\n    \"root\": \"画像ホスティング倉庫\",\n    \"noData\": {\n      \"title\": \"同期機能が有効になっていません\",\n      \"desc\": \"システム設定ページに移動して、Github同期を設定してください。\",\n      \"goToSettings\": \"設定へ移動\",\n      \"howToUse\": \"同期機能の使い方は？\"\n    }\n  },\n  \"navigation\": {\n    \"chat\": \"チャット\",\n    \"record\": \"記録\",\n    \"quickRecord\": \"クイック記録\",\n    \"write\": \"執筆\",\n    \"search\": \"検索\",\n    \"githubImageHosting\": \"Github 画像ホスティング\",\n    \"login\": \"ログイン\",\n    \"loading\": \"読み込み中\",\n    \"view\": \"表示\",\n    \"logout\": \"ログアウト\",\n    \"setting\": \"設定\",\n    \"activity\": \"アクティビティ\",\n    \"files\": \"ノート\",\n    \"outline\": \"アウトライン\",\n    \"hideLeftSidebar\": \"左サイドバーを非表示\",\n    \"showLeftSidebar\": \"左サイドバーを表示\",\n    \"hideRightSidebar\": \"右サイドバーを非表示\",\n    \"showRightSidebar\": \"右サイドバーを表示\",\n    \"searchPlaceholder\": \"ノートまたは記録を検索...\",\n    \"showCenterPanel\": \"显示编辑器\",\n    \"hideCenterPanel\": \"隐藏编辑器\"\n  },\n  \"activity\": {\n    \"title\": \"アクティビティカレンダー\",\n    \"description\": \"記録、対話、執筆の活動を日ごとに確認できます。初版では既存の記録、ユーザー対話、ノート更新時刻をもとに集計します。\",\n    \"drawer\": {\n      \"title\": \"アクティビティ\",\n      \"description\": \"今日の状態と最近の活動傾向をすばやく確認できます。\",\n      \"today\": \"今日\"\n    },\n    \"loading\": \"アクティビティデータを読み込み中...\",\n    \"empty\": \"まだアクティビティデータがありません\",\n    \"refresh\": \"更新\",\n    \"summary\": {\n      \"totalCount\": \"総アクティビティ\",\n      \"activeDays\": \"活動日数\",\n      \"records\": \"記録回数\",\n      \"chats\": \"対話回数\",\n      \"writing\": \"執筆活動\"\n    },\n    \"labels\": {\n      \"record\": \"記録\",\n      \"writing\": \"執筆\",\n      \"chat\": \"対話\"\n    },\n    \"heatmap\": {\n      \"title\": \"直近 26 週間\",\n      \"range\": \"{startDate} - {endDate}\",\n      \"less\": \"少\",\n      \"more\": \"多\",\n      \"dayCount\": \"件の活動\",\n      \"emptyDay\": \"活動なし\"\n    },\n    \"detail\": {\n      \"title\": \"当日の詳細\",\n      \"empty\": \"日付を選択すると当日の活動詳細が表示されます。\"\n    }\n  },\n  \"chat\": {\n    \"placeholder\": {\n      \"default\": \"メッセージを入力...\",\n      \"noApiKey\": \"無 API KEY 設定、AI 話題できません...\",\n      \"on\": \"AIサジェスト有効\",\n      \"off\": \"AIサジェスト無効\",\n      \"noPrimaryModel\": \"無主模型設定、AI 話題できません...\"\n    },\n    \"translate\": {\n      \"tooltip\": \"翻訳\",\n      \"translating\": \"翻訳中...\",\n      \"showOriginal\": \"原文表示\",\n      \"alreadyTranslated\": \"翻訳済み\"\n    },\n    \"clipboardMonitor\": {\n      \"enable\": \"クリップボード監視(有効)\",\n      \"disable\": \"クリップボード監視(無効)\"\n    },\n    \"mcp\": {\n      \"tooltip\": \"MCP サーバー\"\n    },\n    \"send\": \"送信\",\n    \"stop\": \"停止\",\n    \"terminate\": \"終了\",\n    \"tagLink\": {\n      \"on\": \"タグとリンク済み\",\n      \"off\": \"タグとリンクなし\"\n    },\n    \"modelSelect\": {\n      \"tooltip\": \"AIモデルを選択\",\n      \"placeholder\": \"AIモデルを検索\",\n      \"noModel\": \"モデルが見つかりません\"\n    },\n    \"promptSelect\": {\n      \"tooltip\": \"プロンプトを選択\",\n      \"placeholder\": \"プロンプトを検索\"\n    },\n    \"clearChat\": \"会話をクリア\",\n    \"clearContext\": {\n      \"tooltip\": \"コンテキストをクリア\"\n    },\n    \"chatLanguage\": {\n      \"tooltip\": \"会話言語を選択\",\n      \"placeholder\": \"言語を検索\"\n    },\n    \"rag\": {\n      \"notSupported\": \"ベクトルモデルが利用できません\",\n      \"enabled\": \"知識ベース検索有効\",\n      \"disabled\": \"知識ベース検索無効\"\n    },\n    \"modeSelect\": {\n      \"tooltip\": \"入力モードを選択\",\n      \"chat\": \"チャットモード\",\n      \"gen\": \"整理モード\",\n      \"translate\": \"翻訳モード\"\n    },\n    \"fileLink\": {\n      \"tooltip\": \"ファイルをリンク\",\n      \"selectFile\": \"ファイルを選択\",\n      \"linkedFile\": \"リンクされたファイル\",\n      \"searchPlaceholder\": \"ファイルを検索...\",\n      \"noFiles\": \"ファイルが見つかりません\",\n      \"loading\": \"読み込み中...\"\n    }\n  },\n  \"header\": {\n    \"configApiKey\": \"API KEY の設定\",\n    \"clearChat\": \"チャットをクリア\",\n    \"configPrompt\": \"マスクの設定\",\n    \"selectPrompt\": \"マスクを選択\",\n    \"noModel\": \"AI モデルが選択されていません\"\n  },\n  \"clipboard\": {\n    \"image\": {\n      \"detected\": \"クリップボードに画像が存在します：\",\n      \"recording\": \"記録中\",\n      \"recorded\": \"記録済み\",\n      \"record\": \"記録\"\n    },\n    \"text\": {\n      \"detected\": \"クリップボードにテキストが存在します：\",\n      \"recorded\": \"記録済み\",\n      \"record\": \"記録\"\n    }\n  },\n  \"messageControl\": {\n    \"words\": \"字\",\n    \"readAloud\": \"音声読み上げ\",\n    \"playing\": \"再生中\",\n    \"loading\": \"準備中\",\n    \"stop\": \"停止\",\n    \"copy\": \"コピー\",\n    \"copied\": \"コピーしました\"\n  },\n  \"ragSources\": {\n    \"label\": \"参照ファイル\"\n  },\n  \"preview\": {\n    \"close\": \"关闭\",\n    \"copy\": \"复制\",\n    \"copied\": \"已复制！\"\n  },\n  \"control\": {\n    \"edit\": \"編集\",\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"削除\",\n    \"deleteConfirm\": \"本当に削除しますか？\"\n  },\n  \"content\": {\n    \"organize\": \"将你的記録整理为文章：\"\n  },\n  \"note\": {\n    \"organize\": \"整理\",\n    \"writing\": \"執筆\",\n    \"convert\": \"文章に変換\",\n    \"description\": \"現在のノートはAIによって生成され、編集できません。ノートを文章（ローカルファイル）に変換して、執筆ページで二次創作できます。\",\n    \"filename\": \"ファイル名\",\n    \"selectFolder\": \"ファイル夹を選択\",\n    \"rootDirectory\": \"ルートディレクトリ\",\n    \"deleteTag\": \"削除タグ、記録とノート（ゴミ箱で復元可能）\",\n    \"warning\": \"変換後、執筆ページに移動します。\",\n    \"convert_button\": \"変換\",\n    \"organizeAs\": \"記録を整理します...\",\n    \"templateContent\": \"テンプレート内容\",\n    \"recordRange\": \"記録範囲を選択\",\n    \"filterThinkingContent\": \"思考の記録を削除します\",\n    \"startOrganize\": \"開始整理\",\n    \"manageTemplate\": \"管理テンプレート\",\n    \"cancel\": \"キャンセル\"\n  },\n  \"search\": {\n    \"placeholder\": \"ノートと記事を検索...\",\n    \"results\": \"{count} 件の検索結果\",\n    \"noResults\": \"検索結果がありません\",\n    \"tryDifferentKeywords\": \"別のキーワードで検索してみてください\",\n    \"item\": {\n      \"record\": \"記録\",\n      \"article\": \"記事\",\n      \"matches\": \"{count}件の一致\",\n      \"scanType\": \"スキャン\"\n    }\n  },\n  \"marks\": {\n    \"types\": {\n      \"screenshot\": \"スクリーンショット\",\n      \"text\": \"テキスト\",\n      \"image\": \"イラスト\"\n    }\n  },\n  \"tags\": {\n    \"inspiration\": \"インスピレーション\"\n  },\n  \"sync\": {\n    \"status\": \"同期リポジトリの状態\",\n    \"imageRepo\": \"画像ホスティング倉庫\",\n    \"articleRepo\": \"記事倉庫\"\n  },\n  \"ai\": {\n    \"thinking\": \"考えることです\",\n    \"error\": {\n      \"title\": \"AIエラー\",\n      \"noAddress\": \"まずAIアドレスを設定してください\"\n    }\n  },\n  \"article\": {\n    \"sync\": {\n      \"syncingRemote\": \"リモートファイルをプル中...\",\n      \"syncComplete\": \"同期完了\",\n      \"pullingRemote\": \"リモートサーバーから最新コンテンツを取得中...\"\n    },\n    \"syncConfirm\": {\n      \"title\": \"リモートファイルの更新を検出\",\n      \"description\": \"ファイル {fileName} にリモート更新があります\",\n      \"commitInfo\": \"最新コミット情報\",\n      \"commitMessage\": \"コミットメッセージ\",\n      \"author\": \"作者\",\n      \"changes\": \"変更\",\n      \"confirmMessage\": \"リモートバージョンをプルしてローカルファイルを上書きしてもよろしいですか？この操作は元に戻せません。\",\n      \"cancel\": \"キャンセル\",\n      \"confirmPull\": \"プルを確認\"\n    },\n    \"emptyState\": {\n      \"title\": \"作成を開始\",\n      \"subtitle\": \"ファイルを選択して編集を開始するか、新しいノートを作成してください\",\n      \"tip\": \"💡 ヒント：左側のファイルマネージャーからファイルを選択することもできます\",\n      \"actions\": {\n        \"newNote\": {\n          \"title\": \"ノートを作成\",\n          \"desc\": \"新しいMarkdownノートを作成\"\n        },\n        \"newRecord\": {\n          \"title\": \"記録を作成\",\n          \"desc\": \"テキスト記録機能を開く\"\n        },\n        \"globalSearch\": {\n          \"title\": \"グローバル検索\",\n          \"desc\": \"ノートの内容を素早く検索\"\n        },\n        \"openWorkspace\": {\n          \"title\": \"ワークスペースを開く\",\n          \"desc\": \"ワークスペースディレクトリを選択または切り替え\"\n        }\n      },\n      \"onboarding\": {\n        \"title\": \"初回ガイド\",\n        \"subtitle\": \"この 3 つのタスクで NoteGen の基本フローを体験できます。\",\n        \"dismiss\": \"オンボーディングをスキップ\",\n        \"reopen\": \"初回ガイドを再表示\",\n        \"start\": \"開始\",\n        \"viewHint\": \"ヒントを見る\",\n        \"continue\": \"次へ進む\",\n        \"completed\": \"完了\",\n        \"allDone\": \"スタートガイドのタスクはすべて完了しました。NoteGen の基本フローは体験済みです。\",\n        \"stepLabel\": \"タスク ({current}/{total})\",\n        \"stepCompletedLabel\": \"完了したタスク ({current}/{total})\",\n        \"afterOrganizeDialog\": {\n          \"title\": \"タスク完了 (2/3)\",\n          \"description\": \"記録をノートに整理できました。次は AI Agent を使って、このノートをバイリンガル版に翻訳してみますか？\",\n          \"confirm\": \"次へ進む\",\n          \"cancel\": \"今はしない\"\n        },\n        \"agentPrompt\": {\n          \"label\": \"サンプルプロンプト\",\n          \"use\": \"このプロンプトを使う\",\n          \"intro\": \"先ほど整理したこのノートを、中国語と英語のバイリンガル版に直接修正してください。\",\n          \"requirement1\": \"\",\n          \"requirement2\": \"\",\n          \"requirement3\": \"\",\n          \"requirement4\": \"\",\n          \"outro\": \"\"\n        },\n        \"steps\": {\n          \"createRecord\": {\n            \"title\": \"最初の記録を作成\",\n            \"desc\": \"サンプル記録を保存して、記録入口を覚えます。\"\n          },\n          \"organizeNote\": {\n            \"title\": \"ノートに整理\",\n            \"desc\": \"記録を正式なノートに整理します。\"\n          },\n          \"aiPolish\": {\n            \"title\": \"Agent でバイリンガル翻訳\",\n            \"desc\": \"AI Agent を使って、整理したノートをバイリンガル版に変換します。\"\n          }\n        },\n        \"completedStates\": {\n          \"create-record\": {\n            \"title\": \"最初の記録を作成しました\",\n            \"desc\": \"クイック記録の入口がどこにあるか分かりました。\"\n          },\n          \"organize-note\": {\n            \"title\": \"記録をノートに整理しました\",\n            \"desc\": \"次は AI でノートを編集してみましょう。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"Agent でノートを処理しました\",\n            \"desc\": \"記録から整理、さらに Agent による処理まで、NoteGen の基本フローを体験できました。\"\n          }\n        },\n        \"spotlight\": {\n          \"create-record\": {\n            \"title\": \"ここからすぐに記録できます\",\n            \"desc\": \"この入口をクリックするとテキスト記録が開き、サンプル内容も自動で入ります。\"\n          },\n          \"organize-note\": {\n            \"title\": \"ここで記録をノートに整理します\",\n            \"desc\": \"このボタンで、保存した記録を Markdown ノートにまとめられます。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"ここで今作成したノートを Agent に渡せます\",\n            \"desc\": \"サンプルプロンプトをチャットに入れて送信すると、Agent が現在のノートを元にバイリンガル版を生成します。\"\n          }\n        }\n      }\n    },\n    \"unsupportedFile\": {\n      \"title\": \"このファイルをプレビューできません\",\n      \"fileName\": \"ファイル名\",\n      \"filePath\": \"ファイルパス\",\n      \"fileSize\": \"ファイルサイズ\",\n      \"modifiedTime\": \"更新日時\",\n      \"createdTime\": \"作成日時\",\n      \"pathCopied\": \"パスをコピーしました\",\n      \"openExternal\": \"外部アプリで開く\",\n      \"openDirectory\": \"ファイルのディレクトリを開く\"\n    },\n    \"file\": {\n      \"toolbar\": {\n        \"accessRepo\": \"リポジトリにアクセス\",\n        \"loadingSync\": \"同期情報を読み込み中\",\n        \"configSync\": \"同期を設定\",\n        \"newArticle\": \"新規記事\",\n        \"newFolder\": \"新規フォルダ\",\n        \"refresh\": \"更新\",\n        \"toggleFolders\": \"フォルダを展開/折りたたみ\",\n        \"expandAll\": \"すべて展開\",\n        \"collapseAll\": \"すべて折りたたみ\",\n        \"sortByName\": \"名前順で並べ替え\",\n        \"sortByCreated\": \"作成日時で並べ替え\",\n        \"sortByModified\": \"更新日時で並べ替え\",\n        \"sortAsc\": \"昇順\",\n        \"sortDesc\": \"降順\",\n        \"sort\": \"並べ替え\",\n        \"hideCloudFiles\": \"クラウドファイルを非表示\",\n        \"showCloudFiles\": \"クラウドファイルを表示\",\n        \"processingVectors\": \"ベクトルデータ処理中\",\n        \"calculateVectors\": \"知識庫計算（全量）\",\n        \"importMarkdown\": \"インポート\",\n        \"importing\": \"インポート中...\",\n        \"importSuccess\": \"インポート成功\",\n        \"importSuccessDesc\": \"{count} 個のファイルをインポートしました\",\n        \"importError\": \"インポート失敗\"\n      },\n      \"sync\": {\n        \"syncingRemote\": \"リモートファイルをプル中...\",\n        \"syncComplete\": \"同期完了\",\n        \"pullingRemote\": \"リモートサーバーから最新コンテンツを取得中...\",\n        \"pullComplete\": \"プル完了\"\n      },\n      \"context\": {\n        \"viewDirectory\": \"ディレクトリを表示\",\n        \"cut\": \"切り取り\",\n        \"copy\": \"コピー\",\n        \"paste\": \"貼り付け\",\n        \"rename\": \"名前を変更\",\n        \"deleteSyncFile\": \"同期ファイルを削除\",\n        \"deleteLocalFile\": \"ローカルファイルを削除\",\n        \"delete\": \"削除\",\n        \"confirmDelete\": \"フォルダ \\\"{name}\\\" を削除してもよろしいですか？この操作により、フォルダとその内容がすべて削除されます。\",\n        \"deleteSuccess\": \"削除しました\",\n        \"deleteFailed\": \"削除に失敗しました\",\n        \"newFile\": \"新規ファイル\",\n        \"newFolder\": \"新規フォルダ\",\n        \"syncFolder\": \"同期\",\n        \"syncFolderDesc\": \"現在のフォルダ内のすべての Markdown ファイルを同期\",\n        \"syncFolderSuccess\": \"フォルダの同期に成功しました\",\n        \"syncFolderError\": \"フォルダの同期に失敗しました\",\n        \"syncFolderProgress\": \"フォルダを同期中...\",\n        \"deleteSyncFileSuccess\": \"削除しました\",\n        \"deleteSyncFileError\": \"削除に失敗しました\",\n        \"knowledgeBase\": \"ナレッジベース\",\n        \"calculateVectors\": \"ベクトル計算\",\n        \"updateVectors\": \"ベクトル更新\",\n        \"deleteVectors\": \"ベクトル削除\",\n        \"includeInKB\": \"ナレッジベースに含める\",\n        \"includeInKBFile\": \"ナレッジベースに含める\",\n        \"autoVectorCalc\": \"自動ベクトル計算\",\n        \"vectorCalculated\": \"ベクトルを更新しました\",\n        \"vectorCalcCompleted\": \"ベクトル計算が完了しました\",\n        \"vectorCalcFailed\": \"ベクトル計算に失敗しました\",\n        \"vectorDeleted\": \"ベクトルを削除しました\",\n        \"vectorDeleteFailed\": \"ベクトルの削除に失敗しました\",\n        \"batchCalcSuccess\": \"{count} 個のファイルのベクトル計算が成功しました\",\n        \"batchCalcPartial\": \"計算完了：{success} 件成功、{failed} 件失敗\",\n        \"batchCalcFailed\": \"一括ベクトル計算が失敗しました\",\n        \"batchDeleteSuccess\": \"{count} 個のファイルのベクトル削除が成功しました\",\n        \"batchDeletePartial\": \"削除完了：{success} 件成功、{failed} 件失敗\",\n        \"batchDeleteFailed\": \"一括ベクトル削除が失敗しました\",\n        \"noMarkdownFiles\": \"フォルダに Markdown ファイルがありません\",\n        \"includedInKB\": \"ナレッジベースに含めました\",\n        \"excludedFromKB\": \"ナレッジベースから除外しました\",\n        \"autoCalcEnabled\": \"自動ベクトル計算を有効にしました\",\n        \"autoCalcDisabled\": \"自動ベクトル計算を無効にしました\",\n        \"settingFailed\": \"設定に失敗しました\",\n        \"confirmDeleteVectors\": \"{count} 個のファイルのベクトルを削除してもよろしいですか？\"\n      },\n      \"folderView\": {\n        \"vectorDbNotEnabled\": \"ベクターデッサースが有効ではありません\",\n        \"calculateVectors\": \"ベクトルを計算\",\n        \"indexed\": \"インデックス済み\",\n        \"vectorCount\": \"ベクター数\",\n        \"databaseSize\": \"データベースサイズ\",\n        \"lastCalculated\": \"最終計算\",\n        \"never\": \"なし\",\n        \"calculating\": \"計算中...\",\n        \"failed\": \"失敗\",\n        \"recalculateVectors\": \"ベクターを再計算\",\n        \"skills\": \"Skills\",\n        \"skillNotFound\": \"Skill が見つかりません\",\n        \"skillNotFoundDesc\": \"ID {id} の Skill が見つかりません\",\n        \"loadingSkills\": \"Skills を読み込み中...\",\n        \"loadingSkill\": \"Skill を読み込み中...\",\n        \"globalSkills\": \"グローバル Skills\",\n        \"workspaceSkills\": \"ワークスペース Skills\",\n        \"instructions\": \"命令\",\n        \"examples\": \"例\",\n        \"scripts\": \"スクリプト\",\n        \"references\": \"参考資料\",\n        \"assets\": \"アセット\"\n      },\n      \"error\": {\n        \"fileExists\": \"ファイル名が既に存在します\"\n      },\n      \"clipboard\": {\n        \"copied\": \"クリップボードにコピーしました\",\n        \"cut\": \"クリップボードに切り取りました\",\n        \"pasted\": \"貼り付けました\",\n        \"pasteFailed\": \"貼り付け操作に失敗しました\",\n        \"empty\": \"クリップボードが空です\",\n        \"confirmOverwrite\": \"ファイルが既に存在します。覆盖吗？\",\n        \"notSupported\": \"この操作はサポートされていません\"\n      },\n      \"deleteConfirm\": \"このファイルを削除してもよろしいですか？\"\n    },\n    \"editor\": {\n      \"copySuccess\": \"コピー成功\",\n      \"copySuccessDescription\": \"クリップボードにコピーしました\",\n      \"search\": {\n        \"placeholder\": \"ドキュメント内を検索\",\n        \"replacePlaceholder\": \"置換後の文字列\",\n        \"caseSensitive\": \"大文字小文字を区別\",\n        \"replace\": \"置換\",\n        \"replaceAll\": \"すべて置換\",\n        \"findPrev\": \"前へ\",\n        \"findNext\": \"次へ\"\n      },\n      \"upload\": {\n        \"error\": \"画像のアップロードに失敗しました\",\n        \"needToken\": \"画像のアップロードには accessToken の設定が必要です\",\n        \"uploading\": \"画像をアップロードしています\"\n      },\n      \"floatbar\": {\n        \"quote\": {\n          \"tooltip\": \"引用\"\n        },\n        \"readAloud\": {\n          \"start\": \"読み上げ\",\n          \"stop\": \"停止\",\n          \"loading\": \"読み込み中...\"\n        }\n      },\n      \"toolbar\": {\n        \"organize\": {\n          \"tooltip\": \"ノート整理\"\n        },\n        \"mark\": {\n          \"title\": \"記録を使用\",\n          \"tooltip\": \"記録を使用\",\n          \"description\": \"記録をコンテンツとして記事に挿入する。\",\n          \"noRecords\": \"記録なし\",\n          \"ocrNoContent\": \"OCRはコンテンツを認識しませんでした\"\n        },\n        \"question\": {\n          \"tooltip\": \"質問応答\",\n          \"selectContent\": \"先に内容を選択してください\",\n          \"promptTemplate\": \"参考文：\\n{content}\\n質問内容：\\n{question}\\nに基づいて、直接回答を返してください。\"\n        },\n        \"continue\": {\n          \"tooltip\": \"文章続き\",\n          \"promptTemplate\": \"前文：\\n{content}\\n内容に基づいて、100文字以内で続きを書いてください。\\n後文：\\n{endContent}\\nを参考にしても良いですが、重複は避けてください。\"\n        },\n        \"polish\": {\n          \"tooltip\": \"最適化\",\n          \"selectContent\": \"先に内容を選択してください\",\n          \"promptTemplate\": \"この文章を校正してください：\\n{content}\\n。言語はそのままに、誤字脱字や不自然な表現を修正し、校正後の結果を直接返してください。\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"簡潔化\",\n          \"selectContent\": \"先に内容を選択してください\",\n          \"promptTemplate\": \"この文章を要約してください：\\n{content}\\n。文章があまりに冗長なので、文字数を半分以上に減らしてください。言語は変更しないで、要約した結果を直接返してください。\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"拡張\",\n          \"selectContent\": \"先に内容を選択してください\",\n          \"promptTemplate\": \"この文章を拡張してください：\\n{content}\\n。文章があまりに短いので、文字数を半分以上に増やしてください。言語は変更しないで、拡張した結果を直接返してください。\"\n        },\n        \"translation\": {\n          \"tooltip\": \"翻訳\",\n          \"description\": \"選択したテキストを翻訳します\",\n          \"selectContent\": \"まず内容を選択してください\",\n          \"promptTemplate\": \"このテキスト：\\n{content}\\nを{language}に翻訳してください。翻訳結果のみを返してください。\",\n          \"fail\": \"翻訳に失敗しました\",\n          \"failNoSelection\": \"翻訳するテキストを選択してください\",\n          \"translating\": \"翻訳中\",\n          \"translatingTo\": \"{language}に翻訳中...\",\n          \"success\": \"翻訳完了\",\n          \"successTo\": \"{language}に翻訳されました\",\n          \"customLanguage\": \"カスタム言語...\",\n          \"customLanguagePlaceholder\": \"目標言語を入力、例：英語、日本語など\",\n          \"customLanguageEmpty\": \"目標言語を入力してください\",\n          \"customLanguageExample\": \"例：英語、日本語、フランス語など\"\n        }\n      },\n      \"saveDialog\": {\n        \"title\": \"保存文件\",\n        \"emptyContent\": \"内容为空\",\n        \"emptyContentDesc\": \"请先输入内容后再保存\",\n        \"success\": \"保存成功\",\n        \"successDesc\": \"文件已保存\",\n        \"error\": \"保存失败\",\n        \"errorDesc\": \"文件保存失败，请重试\"\n      }\n    },\n    \"footer\": {\n      \"wordCount\": \"文字数\",\n      \"pull\": {\n        \"pull\": \"プル\",\n        \"checking\": \"更新を確認中...\",\n        \"noUpdate\": \"リモート更新なし\",\n        \"clickToPull\": \"クリックしてリモート更新をプル\",\n        \"pullSuccess\": \"プル成功\",\n        \"pullFailed\": \"プル失敗\",\n        \"ignored\": \"無視しました\",\n        \"ignoreUpdate\": \"この更新を無視\"\n      },\n      \"sync\": {\n        \"push\": \"プッシュ\",\n        \"pushed\": \"プッシュ済み\",\n        \"syncing\": \"プッシュ中\",\n        \"syncFailed\": \"プッシュに失敗しました\",\n        \"checkNetworkOrToken\": \"ネットワーク接続またはトークンを確認してください\",\n        \"quickSync\": \"クイック同期\"\n      },\n      \"history\": {\n        \"loadingHistory\": \"履歴を読み込み中\",\n        \"historyRecords\": \"履歴記録\",\n        \"noHistory\": \"履歴がありません\",\n        \"loading\": \"読み込み中\",\n        \"recordsCount\": \"件の記録\",\n        \"filterQuickSync\": \"クイック同期を絞り込み\",\n        \"committedAt\": \"コミット日時\",\n        \"pull\": \"プル\",\n        \"quickSync\": \"クイック同期\"\n      },\n      \"vectorCalc\": {\n        \"tooltip\": {\n          \"default\": \"ベクトルインデックス状態\",\n          \"none\": \"クリックしてベクトル計算を開始\",\n          \"indexed\": \"インデックス済み\",\n          \"pending\": \"更新待ち、クリックして今すぐ計算\",\n          \"calculating\": \"計算中...\"\n        },\n        \"status\": {\n          \"calculating\": \"計算中\"\n        }\n      }\n    }\n  },\n  \"mobile\": {\n    \"chat\": {\n      \"drawer\": {\n        \"settings\": {\n          \"title\": \"チャット設定\"\n        },\n        \"tools\": {\n          \"title\": \"ツール\",\n          \"clearContext\": \"コンテキストをクリア\",\n          \"clearContextDesc\": \"会話のコンテキストをクリアし、チャット履歴を保持\",\n          \"clearChat\": \"チャットをクリア\",\n          \"clearChatDesc\": \"すべてのチャット記録を削除\",\n          \"clear\": \"クリア\",\n          \"newChat\": \"开始新对话\",\n          \"start\": \"开始\"\n        },\n        \"attachments\": {\n          \"title\": \"添付ファイル\",\n          \"gallery\": \"ギャラリー\",\n          \"camera\": \"カメラ\",\n          \"file\": \"ファイル\",\n          \"linkNote\": \"ノートをリンク\"\n        }\n      }\n    }\n  },\n  \"mcp\": {\n    \"selectServers\": \"MCP Servers\",\n    \"searchServers\": \"サーバーを検索...\",\n    \"noServers\": \"MCPサービスが有効になっていません\",\n    \"noServersFound\": \"一致するサーバーが見つかりません\",\n    \"addServer\": \"サーバーを追加...\",\n    \"goToSettings\": \"設定へ移動\",\n    \"close\": \"閉じる\",\n    \"navigate\": \"選択\",\n    \"confirm\": \"確認\",\n    \"tools\": \"個のツール\",\n    \"connecting\": \"接続中\",\n    \"disconnected\": \"未接続\"\n  },\n  \"recording\": {\n    \"title\": \"音声録音\",\n    \"description\": \"マイクボタンをクリックして録音を開始すると、システムが自動的に認識してテキストに変換します\",\n    \"recording\": \"録音中\",\n    \"paused\": \"一時停止\",\n    \"ready\": \"準備完了\",\n    \"processing\": \"処理中...\",\n    \"cancel\": \"キャンセル\",\n    \"error\": \"エラー\",\n    \"success\": \"成功\",\n    \"noModelConfigured\": \"音声認識モデルが設定されていません。設定で構成してください\",\n    \"speechUnavailable\": \"現在の認識モードは利用できません。ローカル音声対応またはモデル設定を確認してください。\",\n    \"fallbackToModel\": \"ローカル音声認識が利用できないため、自動的にモデル文字起こしへ切り替えました。\",\n    \"startError\": \"録音を開始できません\",\n    \"noAudioData\": \"音声データが録音されていません\",\n    \"transcriptionSuccess\": \"音声認識完了\",\n    \"transcriptionEmpty\": \"認識結果が空です\",\n    \"transcriptionError\": \"音声認識に失敗しました\",\n    \"configureModel\": \"モデルを設定\",\n    \"retryTranscription\": \"再認識\",\n    \"retrying\": \"再認識中...\",\n    \"retrySuccess\": \"再認識が完了しました\",\n    \"retryError\": \"再認識に失敗しました\",\n    \"noContentDetected\": \"コンテンツが検出されませんでした\",\n    \"doubleClickToSelectFile\": \"ダブルクリックで音声ファイルを選択\",\n    \"mode\": {\n      \"builtin\": \"ブラウザ認識\",\n      \"builtinDesc\": \"無料、リアルタイム認識\",\n      \"model\": \"AIモデル認識\",\n      \"modelDesc\": \"STTモデルが必要、より正確\"\n    }\n  },\n  \"quickRecord\": {\n    \"description\": \"記録ツールを選択して、素早く記録を作成\"\n  },\n  \"editor\": {\n    \"placeholder\": \"「/」を入力してメニューを開くか、直接書き始める...\",\n    \"outline\": {\n      \"title\": \"アウトライン\",\n      \"open\": \"アウトラインを開く\",\n      \"close\": \"アウトラインを閉じる\"\n    },\n    \"translation\": {\n      \"fail\": \"翻訳に失敗しました\",\n      \"failNoSelection\": \"翻訳するテキストを選択してください\",\n      \"translating\": \"翻訳中...\",\n      \"translatingTo\": \"{language}に翻訳中...\",\n      \"success\": \"翻訳完了\",\n      \"successTo\": \"{language}に翻訳されました\",\n      \"customLanguageEmpty\": \"目標言語を入力してください\",\n      \"customLanguageExample\": \"例：英語、日本語、フランス語など\"\n    },\n    \"quoteDisplay\": {\n      \"fromFile\": \"{fileName}から引用\",\n      \"line\": \"{fileName}の{line}行目から引用\",\n      \"lines\": \"{fileName}の{start}-{end}行目から引用\"\n    },\n    \"bubbleMenu\": {\n      \"ai\": \"AI\",\n      \"polish\": \"添削\",\n      \"concise\": \"簡潔に\",\n      \"expand\": \"拡張\",\n      \"translate\": \"翻訳\",\n      \"translateSubtitle\": \"翻訳先\",\n      \"quoteToChat\": \"チャットに引用\",\n      \"link\": \"リンク\",\n      \"linkPlaceholder\": \"リンクURLを入力\",\n      \"confirm\": \"確認\",\n      \"cancel\": \"キャンセル\",\n      \"bold\": \"太字\",\n      \"italic\": \"斜体\",\n      \"strike\": \"取り消し線\",\n      \"underline\": \"下線\",\n      \"inlineCode\": \"インラインコード\",\n      \"highlight\": \"ハイライト\",\n      \"blockquote\": \"引用\",\n      \"bulletList\": \"箇条書き\",\n      \"orderedList\": \"番号付きリスト\",\n      \"taskList\": \"タスクリスト\",\n      \"codeBlock\": \"コードブロック\",\n      \"languages\": {\n        \"English\": \"英語\",\n        \"Japanese\": \"日本語\",\n        \"Korean\": \"韓国語\",\n        \"French\": \"フランス語\",\n        \"German\": \"ドイツ語\",\n        \"Spanish\": \"スペイン語\",\n        \"Portuguese\": \"ポルトガル語\",\n        \"Russian\": \"ロシア語\",\n        \"Arabic\": \"アラビア語\"\n      },\n      \"customLanguagePlaceholder\": \"カスタム言語...\"\n    },\n    \"aiSuggestion\": {\n      \"accept\": \"受け入れ\",\n      \"reject\": \"拒否\",\n      \"generating\": \"生成中...\",\n      \"abort\": \"中止\"\n    },\n    \"image\": {\n      \"insert\": \"画像を挿入\",\n      \"uploading\": \"アップロード中...\",\n      \"uploadSuccess\": \"画像は画像ホスティングにアップロードされました\",\n      \"saveSuccess\": \"画像はローカルに保存されました\",\n      \"uploadFailed\": \"画像の挿入に失敗しました\",\n      \"sizeSmall\": \"小 (25%)\",\n      \"sizeMedium\": \"中 (50%)\",\n      \"sizeLarge\": \"大 (75%)\",\n      \"sizeOriginal\": \"元のサイズ\",\n      \"editAlt\": \"代替テキストを編集\",\n      \"editSrc\": \"URLを編集\",\n      \"altPlaceholder\": \"代替テキストを入力...\",\n      \"srcPlaceholder\": \"画像URLを入力...\",\n      \"delete\": \"画像を削除\",\n      \"confirm\": \"確認\",\n      \"cancel\": \"キャンセル\"\n    },\n    \"mermaid\": {\n      \"rendering\": \"レンダリング中...\",\n      \"renderError\": \"レンダリングエラー\",\n      \"clickToEdit\": \"クリックしてソースを編集\",\n      \"clickToAdd\": \"クリックして диаграммаを追加\",\n      \"placeholder\": \"Mermaid диаграммаコードを入力...\",\n      \"preview\": \"プレビュー\",\n      \"done\": \"完了\",\n      \"diagramTypes\": {\n        \"flowchart\": \"フローチャート\",\n        \"sequence\": \"シーケンス\",\n        \"classDiagram\": \"クラス図\",\n        \"stateDiagram\": \"ステート図\",\n        \"er\": \"ER図\",\n        \"gantt\": \"ガントチャート\",\n        \"pie\": \"円グラフ\",\n        \"journey\": \"ジャーニー\"\n      },\n      \"templates\": {\n        \"flowchart\": \"graph TD\\n    A[開始] --> B[処理]\\n    B --> C[終了]\",\n        \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: こんにちは\\n    Bob-->>Alice: 返信\",\n        \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n        \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n        \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n        \"gantt\": \"gantt\\n    title プロジェクト計画\\n    dateFormat YYYY-MM-DD\\n    section 第一フェーズ\\n    タスク1 :a1, 2024-01-01, 30d\\n    section 第二フェーズ\\n    タスク2 :after a1, 20d\",\n        \"pie\": \"pie title リソース割り当て\\n    \\\"CPU\\\" : 45\\n    \\\"メモリ\\\" : 30\\n    \\\"ストレージ\\\" : 25\",\n        \"journey\": \"journey\\n    title 私の日常\\n    section 午前\\n    通勤 : 7:00, 5\\n    仕事 : 9:00, 8\"\n      }\n    },\n    \"slashCommand\": {\n      \"groups\": {\n        \"ai\": \"AI\",\n        \"heading\": \"見出し\",\n        \"list\": \"リスト\",\n        \"block\": \"ブロック\",\n        \"align\": \"整列\",\n        \"embed\": \"埋め込み\",\n        \"math\": \"数式\",\n        \"chart\": \"図\"\n      },\n      \"items\": {\n        \"continue\": \"続ける\",\n        \"continueDesc\": \"AIが内容を続きます\",\n        \"heading1\": \"見出し1\",\n        \"heading1Desc\": \"大見出し\",\n        \"heading2\": \"見出し2\",\n        \"heading2Desc\": \"中見出し\",\n        \"heading3\": \"見出し3\",\n        \"heading3Desc\": \"小見出し\",\n        \"bulletList\": \"箇条書き\",\n        \"bulletListDesc\": \"シンプルな箇条書きリストを作成\",\n        \"orderedList\": \"番号付きリスト\",\n        \"orderedListDesc\": \"番号付きのリストを作成\",\n        \"taskList\": \"タスクリスト\",\n        \"taskListDesc\": \"チェックボックス付きタスクリストを作成\",\n        \"image\": \"画像\",\n        \"imageDesc\": \"ローカル画像または画像ホスティングを挿入\",\n        \"table\": \"表\",\n        \"tableDesc\": \"表を挿入\",\n        \"blockquote\": \"引用\",\n        \"blockquoteDesc\": \"引用内容をキャプチャ\",\n        \"codeBlock\": \"コードブロック\",\n        \"codeBlockDesc\": \"コードスニペットをキャプチャ\",\n        \"divider\": \"区切り線\",\n        \"dividerDesc\": \"要素の間に区切り線を作成\",\n        \"inlineMath\": \"インライン数式\",\n        \"inlineMathDesc\": \"インラインLaTeX数式を挿入\",\n        \"blockMath\": \"ブロック数式\",\n        \"blockMathDesc\": \"ブロックLaTeX数式を挿入\",\n        \"flowchart\": \"フローチャート\",\n        \"flowchartDesc\": \"フローチャートを挿入\",\n        \"sequence\": \"シーケンス図\",\n        \"sequenceDesc\": \"シーケンス図を挿入\",\n        \"gantt\": \"ガントチャート\",\n        \"ganttDesc\": \"ガントチャートを挿入\",\n        \"classDiagram\": \"クラス図\",\n        \"classDiagramDesc\": \"クラス図を挿入\",\n        \"stateDiagram\": \"状態図\",\n        \"stateDiagramDesc\": \"状態図を挿入\",\n        \"pie\": \"円グラフ\",\n        \"pieDesc\": \"円グラフを挿入\",\n        \"erDiagram\": \"ER図\",\n        \"erDiagramDesc\": \"エンティティ関係図を挿入\",\n        \"journey\": \"ジャーニーマップ\",\n        \"journeyDesc\": \"ユーザージャーニーマップを挿入\"\n      },\n      \"imageUpload\": {\n        \"success\": \"アップロード成功\",\n        \"saveSuccess\": \"保存成功\",\n        \"savePath\": \"保存先: {path}\",\n        \"failed\": \"画像の挿入に失敗しました\"\n      }\n    }\n  },\n  \"tabContext\": {\n    \"close\": \"閉じる\",\n    \"closeOthers\": \"その他を閉じる\",\n    \"closeAll\": \"すべて閉じる\",\n    \"closeLeft\": \"左を閉じる\",\n    \"closeRight\": \"右を閉じる\"\n  }\n}\n"
  },
  {
    "path": "messages/pt-BR.json",
    "content": "{\n  \"app\": {\n    \"title\": \"Gerador de Notas\",\n    \"description\": \"Seu assistente de anotações com IA\"\n  },\n  \"common\": {\n    \"save\": \"Salvar\",\n    \"cancel\": \"Cancelar\",\n    \"delete\": \"Excluir\",\n    \"edit\": \"Editar\",\n    \"create\": \"Criar\",\n    \"theme\": \"Tema\",\n    \"light\": \"Claro\",\n    \"dark\": \"Escuro\",\n    \"system\": \"Sistema\",\n    \"pin\": \"Fixar\",\n    \"unpin\": \"Desafixar\",\n    \"settings\": \"Configurações\",\n    \"back\": \"Voltar\",\n    \"sync\": \"Sincronizar\",\n    \"language\": \"Idioma\",\n    \"confirm\": \"Confirmar\",\n    \"selectPrompt\": \"Selecionar Prompt\",\n    \"prompt\": \"Instrução\",\n    \"success\": \"Sucesso\",\n    \"error\": \"Falha\",\n    \"defaultFileName\": \"Sem Título\",\n    \"restartToApply\": \", por favor reinicie o aplicativo para que a configuração entre em vigor\",\n    \"close\": \"Fechar\",\n    \"open\": \"Abrir\",\n    \"add\": \"Adicionar\",\n    \"remove\": \"Remover\",\n    \"search\": \"Pesquisar\",\n    \"filter\": \"Filtrar\",\n    \"sort\": \"Ordenar\",\n    \"export\": \"Exportar\",\n    \"import\": \"Importar\",\n    \"refresh\": \"Atualizar\",\n    \"loading\": \"Carregando\",\n    \"warning\": \"Aviso\",\n    \"info\": \"Informação\",\n    \"unsaved\": \"Não salvo\",\n    \"saving\": \"Salvando\",\n    \"configureSync\": \"Configurar Sincronização\"\n  },\n  \"settings\": {\n    \"defaultModels\": {\n      \"title\": \"Modelos Padrão\"\n    },\n    \"others\": \"Avançado\",\n    \"general\": {\n      \"title\": \"Configurações Gerais\",\n      \"desc\": \"Aqui, você pode configurar as definições básicas do aplicativo, incluindo tema da interface, idioma e outras opções.\",\n      \"interface\": {\n        \"title\": \"Configurações da Interface\",\n        \"theme\": {\n          \"title\": \"Tema\",\n          \"desc\": \"Escolha o tema de aparência do aplicativo\",\n          \"options\": {\n            \"light\": \"Claro\",\n            \"dark\": \"Escuro\",\n            \"system\": \"Sistema\"\n          }\n        },\n        \"language\": {\n          \"title\": \"Idioma\",\n          \"desc\": \"Escolha o idioma de exibição do aplicativo\"\n        },\n        \"scale\": {\n          \"title\": \"Escala da Interface\",\n          \"desc\": \"Ajuste a escala geral da interface do aplicativo\",\n          \"placeholder\": \"Selecione a proporção da escala\"\n        },\n        \"contentTextScale\": {\n          \"title\": \"Escala do Conteúdo\",\n          \"desc\": \"Ajuste o tamanho do texto no editor e no conteúdo Markdown do chat\"\n        },\n        \"fileManagerTextSize\": {\n          \"title\": \"Tamanho do Texto do Gerenciador de Arquivos\",\n          \"desc\": \"Ajuste o tamanho do texto das listas de arquivos e pastas no gerenciador de arquivos\"\n        },\n        \"recordTextSize\": {\n          \"title\": \"Tamanho do Texto dos Registros\",\n          \"desc\": \"Ajuste o tamanho do texto dos itens de registro na lista de registros\"\n        },\n        \"customCss\": {\n          \"title\": \"CSS Personalizado\",\n          \"desc\": \"Adicione estilos CSS personalizados para substituir os estilos padrão do aplicativo\",\n          \"button\": \"Editar CSS\",\n          \"dialogTitle\": \"CSS Personalizado\",\n          \"dialogDesc\": \"Insira o código CSS personalizado abaixo para substituir os estilos padrão do aplicativo. Clique em salvar para aplicar as alterações.\",\n          \"placeholder\": \"Insira o código CSS personalizado aqui\",\n          \"save\": \"Salvar\",\n          \"cancel\": \"Cancelar\"\n        },\n        \"tray\": {\n          \"enabled\": {\n            \"title\": \"Habilitar Bandeja\",\n            \"desc\": \"Escolher minimizar para bandeja ou fechar aplicativo ao fechar janela\"\n          }\n        },\n        \"customTheme\": {\n          \"title\": \"Cores do tema personalizado\",\n          \"desc\": \"Personalize as cores do tema do aplicativo, incluindo cor de fundo, cor do texto, cor da borda e outras.\",\n          \"button\": \"Editar cores\",\n          \"dialogTitle\": \"Cores do tema personalizado\",\n          \"dialogDesc\": \"Configure cores personalizadas do tema. As alterações são salvas e aplicadas em tempo real, substituindo os temas claro e escuro.\",\n          \"close\": \"Fechar\",\n          \"reset\": \"Redefinir tudo\",\n          \"tabs\": {\n            \"custom\": \"Personalizar\",\n            \"presets\": \"Predefinições\",\n            \"importExport\": \"Importar/Exportar\"\n          },\n          \"export\": {\n            \"title\": \"Exportar configuração\",\n            \"button\": \"Exportar\",\n            \"placeholder\": \"Cole aqui a configuração exportada\"\n          },\n          \"import\": {\n            \"title\": \"Importar configuração\",\n            \"button\": \"Importar\",\n            \"placeholder\": \"Cole aqui a configuração para importar\",\n            \"error\": \"Falha ao importar: formato inválido\",\n            \"success\": \"Importado com sucesso\"\n          },\n          \"presets\": {\n            \"title\": \"Predefinições\",\n            \"desc\": \"Escolha uma paleta de cores pronta\",\n            \"apply\": \"Aplicar\",\n            \"reset\": {\n              \"name\": \"Restaurar padrão\"\n            },\n            \"default\": {\n              \"name\": \"Branco padrão\"\n            },\n            \"ocean\": {\n              \"name\": \"Azul oceano\"\n            },\n            \"forest\": {\n              \"name\": \"Verde floresta\"\n            },\n            \"sunset\": {\n              \"name\": \"Vermelho pôr do sol\"\n            },\n            \"lavender\": {\n              \"name\": \"Roxo lavanda\"\n            },\n            \"midnight\": {\n              \"name\": \"Meia-noite escuro\"\n            },\n            \"deepSea\": {\n              \"name\": \"Azul mar profundo\"\n            },\n            \"darkForest\": {\n              \"name\": \"Verde floresta escuro\"\n            },\n            \"darkViolet\": {\n              \"name\": \"Violeta escuro\"\n            },\n            \"coralWarm\": {\n              \"name\": \"Coral quente\"\n            },\n            \"slateGray\": {\n              \"name\": \"Cinza ardósia\"\n            },\n            \"darkGold\": {\n              \"name\": \"Dourado escuro\"\n            },\n            \"beigeWarm\": {\n              \"name\": \"Bege quente\"\n            },\n            \"beigeDark\": {\n              \"name\": \"Bege escuro\"\n            }\n          },\n          \"colors\": {\n            \"background\": \"Cor de fundo\",\n            \"foreground\": \"Cor do texto\",\n            \"card\": \"Cor de fundo do cartão\",\n            \"cardForeground\": \"Cor do texto do cartão\",\n            \"primary\": \"Cor principal\",\n            \"primaryForeground\": \"Cor do texto (principal)\",\n            \"secondary\": \"Cor secundária\",\n            \"secondaryForeground\": \"Cor do texto (secundária)\",\n            \"third\": \"Cor terciária\",\n            \"thirdForeground\": \"Cor do texto (terciária)\",\n            \"muted\": \"Cor suave\",\n            \"mutedForeground\": \"Cor do texto (suave)\",\n            \"accent\": \"Cor de destaque\",\n            \"accentForeground\": \"Cor do texto (destaque)\",\n            \"border\": \"Cor da borda\",\n            \"shadow\": \"Cor da sombra\"\n          }\n        }\n      },\n      \"tools\": {\n        \"title\": \"Configurações de Ferramentas\",\n        \"chatToolbar\": {\n          \"title\": \"Barra de Ferramentas do Chat\",\n          \"desc\": \"Personalize a ordem de exibição e a visibilidade dos botões da barra de ferramentas do chat para uma experiência de chat personalizada\",\n          \"button\": \"Configurar\",\n          \"dialogTitle\": \"Configurar Barra de Ferramentas do Chat\",\n          \"dialogDesc\": \"Arraste as ferramentas para ajustar a ordem, use as chaves para mostrar ou ocultar\",\n          \"groups\": {\n            \"pc\": \"PC\",\n            \"mobile\": \"Móvel\",\n            \"bottom\": \"Barra de Ferramentas Inferior\",\n            \"topLeft\": \"Barra de Ferramentas Superior - Esquerda\",\n            \"topRight\": \"Barra de Ferramentas Superior - Direita\"\n          }\n        },\n        \"recordToolbar\": {\n          \"title\": \"Barra de Ferramentas de Registro\",\n          \"desc\": \"Personalize a ordem de exibição e a visibilidade dos botões da barra de ferramentas de registro\",\n          \"button\": \"Configurar\",\n          \"dialogTitle\": \"Configurar Barra de Ferramentas de Registro\",\n          \"dialogDesc\": \"Arraste as ferramentas para ajustar a ordem, use as chaves para mostrar ou ocultar\"\n        },\n        \"desc\": \"配置各种工具栏按钮的显示和排序\"\n      }\n    },\n    \"rag\": {\n      \"title\": \"Base de Conhecimento\",\n      \"desc\": \"Aqui, você pode configurar as definições da base de conhecimento, baseada na tecnologia RAG, que converte texto em vetores usando modelos de embedding e, em seguida, realiza buscas inteligentes e respostas através da busca vetorial.\",\n      \"settingsTitle\": \"Configuração de Parâmetros\",\n      \"settingsDesc\": \"Ajustando os parâmetros, você pode controlar com mais precisão o efeito de recuperação da base de conhecimento.\",\n      \"deleteVectorConfirm\": \"Tem certeza de que deseja limpar a base de conhecimento?\",\n      \"deleteVectorSuccess\": \"Base de conhecimento limpa com sucesso\",\n      \"enable\": \"Habilitar busca na base de conhecimento\",\n      \"enableDesc\": \"Habilitar esta opção fará com que a IA pesquise suas notas ao responder perguntas, fornecendo respostas mais precisas.\",\n      \"chunkSize\": \"Tamanho do Chunk\",\n      \"chunkSizeDesc\": \"O número máximo de caracteres para a divisão do texto. Chunks maiores podem conter mais contexto, mas aumentarão a complexidade do cálculo vetorial.\",\n      \"chunkOverlap\": \"Sobreposição de Chunks\",\n      \"chunkOverlapDesc\": \"O número de caracteres sobrepostos entre os chunks de texto. Sobreposições maiores podem manter a continuidade do contexto.\",\n      \"resultCount\": \"Contagem de Resultados\",\n      \"resultCountDesc\": \"O número de documentos relacionados retornados na busca. Mais documentos fornecem mais informação, mas também podem introduzir ruído.\",\n      \"similarityThreshold\": \"Limiar de Similaridade\",\n      \"similarityThresholdDesc\": \"O limiar mínimo de similaridade entre documentos e consultas. Apenas documentos que excederem este limiar serão retornados. O valor varia de 0.0 a 1.0; quanto maior o limiar, mais rigoroso o requisito.\",\n      \"resetToDefaults\": \"Redefinir para Padrões\",\n      \"deleteVector\": \"Limpar Base de Conhecimento\",\n      \"topPDesc\": \"O parâmetro Top P controla a diversidade do texto gerado pelo modelo: quanto menor o valor, mais determinístico; quanto maior, mais variado.\"\n    },\n    \"mcp\": {\n      \"title\": \"MCP\",\n      \"desc\": \"O Protocolo de Contexto de Modelo (MCP) permite que a IA chame ferramentas externas e acesse recursos, expandindo as capacidades da IA.\",\n      \"servers\": \"Lista de Servidores\",\n      \"serversDesc\": \"Gerencie as configurações do servidor MCP. Cada servidor pode fornecer diferentes ferramentas e recursos.\",\n      \"addServer\": \"Adicionar Servidor\",\n      \"addFirstServer\": \"Adicionar Primeiro Servidor\",\n      \"editServer\": \"Editar Servidor\",\n      \"serverName\": \"Nome do Servidor\",\n      \"serverNamePlaceholder\": \"Ex: Servidor de Sistema de Arquivos\",\n      \"serverEnabled\": \"Habilitar Servidor\",\n      \"serverEnabledDesc\": \"Quando habilitado, este servidor se conectará automaticamente e fornecerá ferramentas.\",\n      \"serverType\": \"Tipo de Servidor\",\n      \"stdio\": \"Comando Local\",\n      \"http\": \"Serviço HTTP\",\n      \"command\": \"Comando\",\n      \"args\": \"Argumentos\",\n      \"argsDesc\": \"Argumentos de linha de comando, separados por espaços\",\n      \"env\": \"Variáveis de Ambiente\",\n      \"envDesc\": \"Configuração de variáveis de ambiente em formato JSON\",\n      \"url\": \"URL do Serviço\",\n      \"headers\": \"Cabeçalhos da Requisição\",\n      \"headersDesc\": \"Cabeçalhos da requisição HTTP em formato JSON\",\n      \"testConnection\": \"Testar Conexão\",\n      \"test\": \"Testar\",\n      \"testSuccess\": \"Teste de conexão bem-sucedido\",\n      \"testFailed\": \"Falha no teste de conexão\",\n      \"connected\": \"Conectado\",\n      \"connecting\": \"Conectando\",\n      \"disconnected\": \"Desconectado\",\n      \"error\": \"Erro\",\n      \"tools\": \"Ferramentas\",\n      \"noServers\": \"Serviço MCP não habilitado\",\n      \"noServersFound\": \"Nenhum servidor correspondente encontrado\",\n      \"serverAdded\": \"Servidor adicionado com sucesso\",\n      \"serverUpdated\": \"Servidor atualizado com sucesso\",\n      \"serverDeleted\": \"Servidor excluído com sucesso\",\n      \"deleteServerTitle\": \"Excluir Servidor\",\n      \"deleteServerDesc\": \"Tem certeza de que deseja excluir este servidor? Esta ação não pode ser desfeita.\",\n      \"nameRequired\": \"Por favor, insira o nome do servidor\",\n      \"commandRequired\": \"Por favor, insira o comando\",\n      \"urlRequired\": \"Por favor, insira a URL do serviço\",\n      \"toolBrowser\": \"Navegador de Ferramentas\",\n      \"searchTools\": \"Buscar ferramentas...\",\n      \"noToolsFound\": \"Nenhuma ferramenta encontrada\",\n      \"parameters\": \"Parâmetros\",\n      \"testAll\": \"Testar Todas as Conexões\",\n      \"testAllCompleted\": \"Todos os testes de conexão concluídos\",\n      \"testAllFailed\": \"Falha no teste de conexão\",\n      \"save\": \"Salvar\",\n      \"cancel\": \"Cancelar\",\n      \"delete\": \"Excluir\",\n      \"importJson\": \"Importar JSON\",\n      \"jsonImportTitle\": \"Importar Configuração do Servidor de JSON\",\n      \"jsonImportDesc\": \"Cole o formato de configuração mcpServers dos servidores MCP\",\n      \"jsonInput\": \"Configuração JSON\",\n      \"jsonInputHelp\": \"Suporta formato mcpServers, usa automaticamente o nome do servidor como chave\",\n      \"jsonRequired\": \"Por favor, insira a configuração JSON\",\n      \"jsonEmpty\": \"A configuração JSON não pode estar vazia\",\n      \"jsonInvalidJson\": \"Formato JSON inválido\",\n      \"jsonInvalidFormat\": \"Formato de configuração inválido, deve conter campos name e type\",\n      \"jsonInvalidType\": \"O tipo de servidor deve ser stdio ou http\",\n      \"jsonMissingCommand\": \"Servidor tipo stdio deve especificar command\",\n      \"jsonMissingUrl\": \"Servidor tipo http deve especificar url\",\n      \"jsonImportSuccess\": \"Importado com sucesso {count} servidor(es)\",\n      \"jsonImportSkipped\": \"Ignorado {count} servidor(es) existente(s)\",\n      \"jsonImportNoServers\": \"Nenhum servidor foi importado\",\n      \"import\": \"Importar\",\n      \"mobileHttpOnlyTitle\": \"MCP por comando local é exclusivo do desktop\",\n      \"mobileHttpOnlyDesc\": \"Servidores MCP com comando local só são suportados no desktop. No mobile, o suporte atual é apenas para MCP HTTP.\",\n      \"runtimeEnvironment\": \"Ambiente de runtime\",\n      \"runtimeEnvironmentDesc\": \"Verifique se o runtime local necessário está disponível antes de testar o servidor MCP.\",\n      \"checkEnvironment\": \"Verificar ambiente\",\n      \"recheckEnvironment\": \"Verificar novamente\",\n      \"runtimeCheckFailed\": \"Falha ao verificar o ambiente\",\n      \"detectedLauncher\": \"Launcher detectado\",\n      \"runtimeInstalled\": \"Instalado\",\n      \"runtimeMissing\": \"Ausente\",\n      \"runtimeVersion\": \"Versão\",\n      \"runtimeInstalledSummary\": \"{installed}/{total} instalados\",\n      \"showRuntimeDetails\": \"Mostrar detalhes do runtime\",\n      \"hideRuntimeDetails\": \"Ocultar detalhes do runtime\",\n      \"runtimeNotChecked\": \"Este runtime ainda não foi verificado.\",\n      \"runtimeCurrentUserScope\": \"Quando houver suporte, o comando recomendado instala no ambiente do usuário atual.\",\n      \"runtimeManualOnly\": \"A instalação automática não está disponível para este runtime na plataforma atual. Instale manualmente e verifique novamente.\",\n      \"installRuntime\": \"Instalar runtime\",\n      \"runtimeInstallTitle\": \"Instalar runtime\",\n      \"runtimeInstallDesc\": \"Após sua confirmação, o NoteGen executará o seguinte comando de instalação.\",\n      \"runtimeInstallPreparing\": \"Preparando instalação\",\n      \"runtimeInstallRunning\": \"Instalando\",\n      \"runtimeInstallCompleted\": \"Instalação concluída\",\n      \"runtimeInstallCancelled\": \"Cancelado\",\n      \"runtimeInstallFailedState\": \"Falha na instalação\",\n      \"runtimeInstallLogs\": \"Logs de instalação\",\n      \"runtimeInstallWaitingLogs\": \"Aguardando saída da instalação...\",\n      \"runtimeInstallClose\": \"Fechar\",\n      \"runtimeInstallCancel\": \"Parar instalação\",\n      \"runtimeInstallCancelledByUser\": \"Cancelamento solicitado pelo usuário.\",\n      \"runtimeInstallCancelFailed\": \"Falha ao parar a instalação\",\n      \"runtimeInstallSuccess\": \"Instalação do runtime concluída\",\n      \"runtimeInstallFailed\": \"Falha na instalação do runtime\",\n      \"runtimeNoGuidedSupport\": \"Ainda não há assistência guiada para este comando.\",\n      \"enableTitle\": \"Ativar MCP\",\n      \"enableDesc\": \"Quando ativado, a IA pode chamar as ferramentas fornecidas pelos servidores MCP configurados.\"\n    },\n    \"editor\": {\n      \"title\": \"Configurações do Editor\",\n      \"interfaceSettings\": \"Configurações de Interface\",\n      \"desc\": \"Aqui, você pode personalizar o editor, criando uma experiência de escrita adaptada às suas necessidades.\",\n      \"centeredContent\": \"Conteúdo Centralizado\",\n      \"centeredContentDesc\": \"Quando ativado, o conteúdo do editor será centralizado com margens em ambos os lados.\",\n      \"outlineEnable\": \"Sumário habilitado por padrão\",\n      \"outlineEnableDesc\": \"Habilitar esta opção tornará o sumário visível por padrão.\",\n      \"outlinePosition\": \"Posição do Sumário\",\n      \"outlinePositionDesc\": \"Defina a posição do sumário.\",\n      \"outlinePositionOptions\": {\n        \"left\": \"Esquerda\",\n        \"right\": \"Direita\"\n      },\n      \"showUndoRedo\": \"Botões Desfazer/Refazer\",\n      \"showUndoRedoDesc\": \"Mostrar botões de desfazer e refazer na barra de guias do editor.\",\n      \"completion\": {\n        \"title\": \"Auto Completar\",\n        \"model\": {\n          \"title\": \"Modelo de Completamento Rápido\",\n          \"desc\": \"Selecione o modelo para preenchimento automático no editor\"\n        }\n      },\n      \"commit\": {\n        \"title\": \"Mensagem de Commit Automática\",\n        \"model\": {\n          \"title\": \"Modelo de Commit\",\n          \"desc\": \"Para gerar automaticamente mensagens de commit do Git com base nas alterações do arquivo\"\n        }\n      },\n      \"mermaid\": {\n        \"title\": \"Diagrama\",\n        \"rendering\": \"Renderizando...\",\n        \"renderError\": \"Erro de renderização\",\n        \"clickToEdit\": \"Clique para editar código\",\n        \"clickToAdd\": \"Clique para adicionar diagrama\",\n        \"placeholder\": \"Digite o código do diagrama Mermaid...\",\n        \"preview\": \"Visualizar\",\n        \"done\": \"Concluído\",\n        \"diagramTypes\": {\n          \"flowchart\": \"Fluxograma\",\n          \"sequence\": \"Sequência\",\n          \"classDiagram\": \"Diagrama de Classes\",\n          \"stateDiagram\": \"Diagrama de Estados\",\n          \"er\": \"Diagrama ER\",\n          \"gantt\": \"Gantt\",\n          \"pie\": \"Gráfico de Pizza\",\n          \"journey\": \"Jornada\"\n        },\n        \"templates\": {\n          \"flowchart\": \"graph TD\\n    A[Início] --> B[Processo]\\n    B --> C[Fim]\",\n          \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: Olá\\n    Bob-->>Alice: Resposta\",\n          \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n          \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n          \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n          \"gantt\": \"gantt\\n    title Plano de Projeto\\n    dateFormat YYYY-MM-DD\\n    section Fase 1\\n    Tarefa1 :a1, 2024-01-01, 30d\\n    section Fase 2\\n    Tarefa2 :after a1, 20d\",\n          \"pie\": \"pie title Alocação de Recursos\\n    \\\"CPU\\\" : 45\\n    \\\"Memória\\\" : 30\\n    \\\"Armazenamento\\\" : 25\",\n          \"journey\": \"journey\\n    title Meu Trabalho Diário\\n    section Manhã\\n    Deslocamento : 7:00, 5\\n    Trabalho : 9:00, 8\"\n        }\n      }\n    },\n    \"record\": {\n      \"title\": \"Configurações de Registro\",\n      \"desc\": \"Configure as configurações relacionadas a registros aqui, incluindo descrição de registro e configuração da barra de ferramentas.\",\n      \"model\": {\n        \"title\": \"Configurações do Modelo\",\n        \"markDesc\": {\n          \"title\": \"Descrição do Registro\",\n          \"desc\": \"Para processar registros reconhecidos por OCR e gerar descrições de registros\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"Configurações da Barra de Ferramentas\",\n        \"recordToolbar\": {\n          \"title\": \"Barra de Ferramentas de Registro\",\n          \"desc\": \"Personalize a visibilidade e a ordem dos botões da barra de ferramentas de registro\",\n          \"button\": \"Configurar\",\n          \"text\": {\n            \"desc\": \"记录文本内容\"\n          },\n          \"recording\": {\n            \"desc\": \"录音记录功能\"\n          },\n          \"scan\": {\n            \"desc\": \"扫描识别图片中的文字\"\n          },\n          \"image\": {\n            \"desc\": \"上传图片到笔记\"\n          },\n          \"link\": {\n            \"desc\": \"记录网页链接\"\n          },\n          \"file\": {\n            \"desc\": \"上传文件到笔记\"\n          },\n          \"todo\": {\n            \"desc\": \"创建待办事项\"\n          }\n        }\n      }\n    },\n    \"uploadStore\": {\n      \"uploadConfirm\": \"Configuração de Carregamento: Por favor, certifique-se de que o repositório de sincronização é privado, caso contrário, seus dados serão expostos!\",\n      \"downloadConfirm\": \"Baixar a configuração irá sobrescrever a configuração local e exigirá um reinício para ter efeito!\",\n      \"uploadSuccess\": \"Upload bem-sucedido\",\n      \"downloadSuccess\": \"Download bem-sucedido\",\n      \"upload\": \"Carregar\",\n      \"download\": \"Baixar\"\n    },\n    \"about\": {\n      \"title\": \"Sobre\",\n      \"desc\": \"Um assistente de anotações focado em registro e escrita.\",\n      \"version\": \"NoteGen v{version}\",\n      \"checkReleases\": \"Verificar Histórico de Lançamentos\",\n      \"language\": \"Idioma\",\n      \"checkUpdate\": \"Verificar Atualizações\",\n      \"checkError\": \"Falha ao verificar atualizações\",\n      \"updateAvailable\": \"Atualizar para nova versão\",\n      \"updateDownloading\": \"Atualizando {downloaded} / {contentLength}\",\n      \"updateInstalled\": \"Reiniciar aplicativo\",\n      \"noUpdate\": \"A versão atual é a mais recente\",\n      \"ignoreVersion\": \"Ignorar esta versão\",\n      \"ignoreVersionSuccess\": \"Esta atualização de versão foi ignorada\",\n      \"items\": {\n        \"home\": {\n          \"title\": \"Início\",\n          \"buttonName\": \"Abrir\",\n          \"desc\": \"Visite o site para saber mais sobre o NoteGen.\"\n        },\n        \"guide\": {\n          \"title\": \"Guia\",\n          \"buttonName\": \"Abrir\",\n          \"desc\": \"Veja o guia de configuração, aprenda como configurar modelos, sincronização e outras informações.\"\n        },\n        \"github\": {\n          \"title\": \"GitHub\",\n          \"buttonName\": \"Ver\",\n          \"desc\": \"Se o NoteGen te ajuda, por favor, dê uma estrela para incentivar!\"\n        },\n        \"releases\": {\n          \"title\": \"Log de Atualizações\",\n          \"buttonName\": \"Ver\",\n          \"desc\": \"Veja o log de atualizações, saiba mais sobre as novidades do NoteGen.\"\n        },\n        \"issues\": {\n          \"title\": \"Feedback de Problemas\",\n          \"buttonName\": \"Comentários\",\n          \"desc\": \"Se você encontrar um bug no NoteGen, por favor, reporte aqui.\"\n        },\n        \"discussions\": {\n          \"title\": \"Discussões\",\n          \"buttonName\": \"Discutir\",\n          \"desc\": \"Se você quiser discutir com o autor ou outros usuários, pode se juntar ao grupo de discussão.\"\n        }\n      }\n    },\n    \"memories\": {\n      \"title\": \"Gerenciamento de Memória\",\n      \"desc\": \"Recurso de memória de longo prazo da IA que permite que a IA lembre de suas preferências de escrita, base de conhecimento e hábitos de anotações.\",\n      \"stats\": {\n        \"total\": \"Total de Memórias\",\n        \"preferences\": \"Preferências\",\n        \"knowledge\": \"Conhecimento\",\n        \"memories\": \"记忆\"\n      },\n      \"form\": {\n        \"title\": \"Adicionar Nova Memória\",\n        \"contentLabel\": \"Conteúdo da Memória\",\n        \"contentPlaceholder\": \"ex: Prefiro respostas em chinês, sou um especialista em React...\",\n        \"categoryLabel\": \"Tipo\",\n        \"preferenceDesc\": \"Preferência (idioma, formato, estilo, etc.)\",\n        \"knowledgeDesc\": \"Conhecimento (fatos, experiência, especialização, etc.)\",\n        \"save\": \"Salvar Memória\",\n        \"saving\": \"Salvando...\",\n        \"categoryDescription\": \"记忆分为两种类型：\",\n        \"preferenceDescription\": \"偏好：语言、格式、风格等设置，每次对话都会自动加载\",\n        \"memoryDescription\": \"记忆：事实、经验、专长等信息，根据对话内容智能匹配\",\n        \"preferenceLabel\": \"偏好\",\n        \"memoryLabel\": \"记忆\",\n        \"memoryDesc\": \"事实、经验、专长等\"\n      },\n      \"listTitle\": \"Minhas Memórias\",\n      \"addMemory\": \"Adicionar Memória\",\n      \"empty\": \"Nenhuma memória ainda, adicione sua primeira memória!\",\n      \"emptyHint\": \"Você pode adicionar memórias manualmente ou usar frases como \\\"por favor, lembre-se\\\" ou \\\"lembre-se disso\\\" nas conversas para que a IA crie memórias automaticamente.\",\n      \"preference\": \"Preferência\",\n      \"knowledge\": \"Conhecimento\",\n      \"replaced\": \"Substituído\",\n      \"accessCount\": \"Acessado {count} vezes\",\n      \"tabs\": {\n        \"all\": \"Todas\",\n        \"preference\": \"Preferências\",\n        \"knowledge\": \"Conhecimento\",\n        \"memory\": \"记忆\"\n      },\n      \"success\": \"Sucesso\",\n      \"saved\": \"Memória salva\",\n      \"updated\": \"Memória atualizada (memória similar substituída)\",\n      \"deleted\": \"Memória excluída\",\n      \"cleared\": \"Todas as memórias limpas\",\n      \"found\": \"Encontrado {count} memórias\",\n      \"error\": \"Erro\",\n      \"errorEmpty\": \"Por favor, insira o conteúdo da memória\",\n      \"errorSave\": \"Falha ao salvar\",\n      \"errorDelete\": \"Falha ao excluir\",\n      \"errorList\": \"Falha ao obter lista de memórias\",\n      \"errorEmbedding\": \"Falha ao gerar embedding, verifique a configuração do modelo de embedding\",\n      \"errorClear\": \"Falha ao limpar\",\n      \"memory\": \"记忆\"\n    },\n    \"defaultModel\": {\n      \"title\": \"Modelo Padrão\",\n      \"desc\": \"Aqui, você pode usar diferentes modelos para diferentes cenários, para melhorar a eficiência e reduzir custos.\",\n      \"tooltip\": \"Usar modelo principal\",\n      \"noModel\": \"Não usar\",\n      \"placeholder\": \"Por favor, selecione ou procure por modelos\",\n      \"main\": \"Modelo principal\",\n      \"options\": {\n        \"primaryModel\": {\n          \"title\": \"Modelo principal\",\n          \"desc\": \"Como o modelo principal para todos os cenários, este modelo é usado se outros modelos de diálogo não selecionarem o modelo padrão.\"\n        },\n        \"markDesc\": {\n          \"title\": \"Descrição do Registro\",\n          \"desc\": \"Usado para processar registros após reconhecimento OCR, gerando descrições de registro.\"\n        },\n        \"placeholder\": {\n          \"title\": \"Sugestão da IA\",\n          \"desc\": \"Prompts de sugestão da IA são usados para geração de conteúdo no placeholder da conversa de IA da página de registro.\"\n        },\n        \"completion\": {\n          \"title\": \"Conclusão Rápida\",\n          \"desc\": \"Conclusão inline de IA para editor Markdown, semelhante ao GitHub Copilot, gera rapidamente conteúdo de continuação\"\n        },\n        \"commit\": {\n          \"title\": \"Gerar Mensagem de Commit Automaticamente\",\n          \"desc\": \"Usado para gerar automaticamente mensagens de commit Git, gerando inteligentemente mensagens de commit descritivas com base nas alterações do conteúdo do arquivo.\"\n        },\n        \"translate\": {\n          \"title\": \"Tradução\",\n          \"desc\": \"Usado para cenários de tradução de conteúdo.\"\n        },\n        \"embedding\": {\n          \"title\": \"Modelo de Embedding\",\n          \"desc\": \"Usado para cenários de embedding e vetorização de texto.\"\n        },\n        \"reranking\": {\n          \"title\": \"Modelo de Reordenação\",\n          \"desc\": \"Usado para reordenar e otimizar resultados de busca.\"\n        },\n        \"condense\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"用于压缩历史对话内容，节省 token 使用量\"\n        }\n      },\n      \"mainModel\": \"Modelo principal\"\n    },\n    \"audio\": {\n      \"title\": \"Configurações de Áudio\",\n      \"desc\": \"Aqui, você pode configurar as definições de áudio, incluindo as funções de conversão de texto em fala (TTS) e fala em texto (STT).\",\n      \"mode\": {\n        \"title\": \"Modo\",\n        \"auto\": \"Automático (Recomendado)\",\n        \"local\": \"Somente local\",\n        \"model\": \"Somente modelo\"\n      },\n      \"tts\": {\n        \"title\": \"Texto para Fala (TTS)\",\n        \"desc\": \"Configure a funcionalidade de leitura em voz alta para fornecer reprodução de voz para o conteúdo do chat.\",\n        \"modeDesc\": \"Prefere as vozes do navegador e do sistema por padrão e usa um modelo apenas quando necessário para melhorar a experiência.\",\n        \"model\": {\n          \"title\": \"Modelo TTS\",\n          \"desc\": \"Opcional. Configure um modelo para aprimorar o modo automático ou usar o modo somente modelo.\"\n        },\n        \"speed\": {\n          \"title\": \"Velocidade da Fala\",\n          \"desc\": \"Ajuste a velocidade de reprodução da voz, variando de 0.5x a 2x, sendo 1x a velocidade normal.\"\n        }\n      },\n      \"stt\": {\n        \"title\": \"Fala para Texto (STT)\",\n        \"desc\": \"Configure o reconhecimento de voz para converter fala em registros de texto.\",\n        \"modeDesc\": \"Prefere o reconhecimento nativo do navegador por padrão e usa um modelo como fallback quando o suporte local não estiver disponível.\",\n        \"model\": {\n          \"title\": \"Modelo STT\",\n          \"desc\": \"Opcional. Configure um modelo para fallback automático ou para o modo somente modelo.\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"Ler em Voz Alta\",\n      \"desc\": \"Configure o comportamento da leitura em voz alta. As vozes do sistema são preferidas por padrão, e a voz de modelo funciona como aprimoramento.\",\n      \"options\": {\n        \"mode\": {\n          \"title\": \"Modo\",\n          \"desc\": \"O modo automático prefere as vozes do sistema e só tenta um modelo quando a fala local não está disponível.\",\n          \"auto\": \"Automático (Recomendado)\",\n          \"local\": \"Somente local\",\n          \"model\": \"Somente modelo\"\n        },\n        \"audioModel\": {\n          \"title\": \"Modelo de Leitura\",\n          \"desc\": \"Opcional. Configure um modelo para aprimorar o modo automático ou usar o modo somente modelo.\"\n        },\n        \"speed\": {\n          \"title\": \"Velocidade\",\n          \"desc\": \"Ajuste a velocidade da leitura entre 0.5x e 2x, com 1x como padrão.\"\n        }\n      }\n    },\n    \"prompt\": {\n      \"title\": \"Prompt\",\n      \"promptTitle\": \"Título do Prompt\",\n      \"desc\": \"Aqui, você pode adicionar e gerenciar prompts, ajudando a IA a entender melhor suas necessidades.\",\n      \"addPrompt\": \"Adicionar Prompt\",\n      \"selectPrompt\": \"Selecionar Prompt\",\n      \"configPrompt\": \"Configurar Prompt\",\n      \"noContent\": \"Nenhum conteúdo\",\n      \"addPromptDesc\": \"Por favor, insira o nome e o conteúdo do prompt, ajudando a IA a entender melhor suas necessidades.\",\n      \"promptTitlePlaceholder\": \"Por favor, insira o nome do prompt\",\n      \"promptContentPlaceholder\": \"Por favor, insira o conteúdo do prompt\",\n      \"promptContent\": \"Conteúdo do Prompt\",\n      \"optimizePrompt\": \"Otimizar Prompt\",\n      \"optimizing\": \"Otimizando...\",\n      \"optimizeSuccess\": \"Prompt otimizado com sucesso\",\n      \"optimizeFailed\": \"Falha ao otimizar o prompt, por favor, tente novamente mais tarde\",\n      \"noContentToOptimize\": \"Por favor, insira o conteúdo do prompt primeiro\"\n    },\n    \"sync\": {\n      \"title\": \"Sincronização\",\n      \"desc\": \"Aqui, você pode configurar o repositório de sincronização, que pode ajudá-lo a sincronizar registros, arquivos markdown, configurações do sistema e outras informações.\",\n      \"selectPlatform\": \"Selecionar Plataforma de Sincronização\",\n      \"platformSettings\": \"Selecionar Plataforma\",\n      \"settings\": \"Configurações de Sincronização\",\n      \"platformDesc\": \"Configure o Token e informações do repositório para habilitar a sincronização\",\n      \"moreSettings\": \"Mais Configurações\",\n      \"repoStatus\": \"Status do Repositório\",\n      \"syncRepo\": \"Repositório de Sincronização\",\n      \"syncRepoDesc\": \"Sincronizar arquivos markdown na escrita\",\n      \"imageRepo\": \"Repositório de Imagens\",\n      \"imageRepoDesc\": \"Sincronize suas imagens para o repositório, usando jsdelivr para aceleração\",\n      \"status\": {\n        \"connected\": \"Conectado\",\n        \"disconnected\": \"Desconectado\",\n        \"failed\": \"Conexão Falhou\",\n        \"unconfigured\": \"Não Configurado\"\n      },\n      \"uploadRecords\": \"Enviar Registros e Config\",\n      \"downloadConfig\": \"Baixar Registros e Config\",\n      \"cloudSync\": \"Sinc Registros e Config\",\n      \"localBackupAll\": \"Backup Local (Tudo)\",\n      \"private\": \"Privado\",\n      \"public\": \"Público\",\n      \"createdAt\": \"Criado {time}\",\n      \"updatedAt\": \"Última atualização {time}\",\n      \"newToken\": \"Criar Token de Acesso\",\n      \"newTokenDesc\": \"Ao criar um novo token, por favor, certifique-se de marcar a permissão 'repo', e após a configuração, ele criará automaticamente um repositório de arquivos (privado) e um repositório de imagens.\",\n      \"giteeTokenDesc\": \"O token de acesso pessoal do Gitee é usado para sincronização de dados. Ele precisa de permissões de leitura e escrita do repositório. Após a configuração, ele criará automaticamente um repositório de arquivos (privado) e um repositório de imagens.\",\n      \"imageRepoSetting\": \"Ativar Hospedagem de Imagens\",\n      \"imageRepoSettingDesc\": \"Você já configurou um repositório de imagens, pode escolher usar o repositório de imagens ou usar o armazenamento local.\",\n      \"jsdelivrSetting\": \"jsDelivr\",\n      \"autoSyncDesc\": \"Quando habilitado, o editor sincronizará automaticamente com o GitHub 10 segundos após a interrupção da entrada\",\n      \"giteeAutoSyncDesc\": \"Quando habilitado, o editor sincronizará automaticamente com o Gitee 10 segundos após a interrupção da entrada\",\n      \"customSyncRepo\": \"Nome Personalizado do Repositório de Sincronização\",\n      \"customSyncRepoDesc\": \"Deixe em branco para usar o nome do repositório padrão\",\n      \"customImageRepo\": \"Nome Personalizado do Repositório de Imagens\",\n      \"customImageRepoDesc\": \"Deixe em branco para usar o nome do repositório padrão\",\n      \"backupMethod\": \"Método de Backup\",\n      \"backupMethodDesc\": \"Após definir como o método de backup principal, todas as funções relacionadas à sincronização na escrita usarão o método de backup atual (exceto para hospedagem de imagens)\",\n      \"createRepo\": \"Criar Repositório\",\n      \"creating\": \"Criando\",\n      \"checkRepo\": \"Verificar Repositório\",\n      \"checking\": \"Verificando\",\n      \"enterToken\": \"Por favor, insira o Access Token\",\n      \"enterTokenHint\": \"Por favor, insira o Access Token primeiro para verificar o status do repositório\",\n      \"defaultRepoName\": \"Padrão: {name}\",\n      \"gitlabInstanceType\": \"Tipo de Instância GitLab\",\n      \"gitlabInstanceTypeDesc\": \"Selecione o tipo de instância GitLab para conectar\",\n      \"gitlabInstanceTypePlaceholder\": \"Selecione o Tipo de Instância GitLab\",\n      \"gitlabInstanceTypeOptions\": {\n        \"selfHosted\": \"Instância Auto-hospedada\",\n        \"selfHostedDesc\": \"Insira o endereço do seu servidor GitLab auto-hospedado (ex: https://gitlab.example.com)\"\n      },\n      \"gitlabAccessTokenDesc\": \"Crie um token de acesso pessoal em {instanceDisplayName}, requer permissão 'api'\",\n      \"giteaInstanceType\": \"Tipo de Instância Gitea\",\n      \"giteaInstanceTypeDesc\": \"Selecione o tipo de instância Gitea para conectar\",\n      \"giteaInstanceTypePlaceholder\": \"Selecione o Tipo de Instância Gitea\",\n      \"giteaInstanceTypeOptions\": {\n        \"selfHosted\": \"Instância Auto-hospedada\",\n        \"selfHostedDesc\": \"Insira o endereço do seu servidor Gitea auto-hospedado (ex: https://gitea.example.com)\"\n      },\n      \"giteaAccessTokenDesc\": \"Crie um token de acesso pessoal em {instanceDisplayName}, requer permissão total de repositório\",\n      \"s3\": {\n        \"title\": \"Sincronização S3\",\n        \"description\": \"Sincronize suas notas usando armazenamento compatível com S3\",\n        \"status\": \"Status da Conexão\",\n        \"connected\": \"Conectado\",\n        \"connecting\": \"Conectando\",\n        \"disconnected\": \"Desconectado\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"Por favor, insira o Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"Por favor, insira o Secret Access Key\",\n        \"region\": \"Região\",\n        \"bucket\": \"Bucket\",\n        \"bucketPlaceholder\": \"Por favor, insira o nome do bucket\",\n        \"endpoint\": \"Endpoint\",\n        \"pathPrefix\": \"Prefixo do Caminho\",\n        \"pathPrefixPlaceholder\": \"Por favor, insira o prefixo do caminho\",\n        \"pathPrefixDesc\": \"Usado para diferenciar arquivos entre diferentes usuários, similar ao nome do repositório\",\n        \"customDomain\": \"Domínio Personalizado\",\n        \"testConnection\": \"Testar Conexão\",\n        \"testing\": \"Testando\",\n        \"saveConfig\": \"Salvar Configuração\",\n        \"saving\": \"Salvando\"\n      },\n      \"webdav\": {\n        \"title\": \"Sincronização WebDAV\",\n        \"description\": \"Sincronize suas notas usando o protocolo WebDAV\",\n        \"status\": \"Status da Conexão\",\n        \"connected\": \"Conectado\",\n        \"connecting\": \"Conectando\",\n        \"disconnected\": \"Desconectado\",\n        \"url\": \"URL do Servidor\",\n        \"urlPlaceholder\": \"Por favor, insira a URL do servidor WebDAV\",\n        \"urlDesc\": \"Suporta Synology, QNAP, Nextcloud e outros serviços WebDAV\",\n        \"username\": \"Nome de Usuário\",\n        \"usernamePlaceholder\": \"Por favor, insira o nome de usuário\",\n        \"password\": \"Senha\",\n        \"passwordPlaceholder\": \"Por favor, insira a senha\",\n        \"pathPrefix\": \"Prefixo do Caminho\",\n        \"pathPrefixPlaceholder\": \"Por favor, insira o prefixo do caminho\",\n        \"pathPrefixDesc\": \"Usado para diferenciar arquivos entre diferentes usuários\",\n        \"testConnection\": \"Testar Conexão\",\n        \"testing\": \"Testando\",\n        \"saveConfig\": \"Salvar Configuração\",\n        \"saving\": \"Salvando\"\n      },\n      \"autoSync\": \"Sincronização Automática\",\n      \"autoSyncOptions\": {\n        \"placeholder\": \"Selecione o tempo de sincronização automática\",\n        \"disabled\": \"Desabilitado\",\n        \"2s\": \"2 segundos\",\n        \"3s\": \"3 segundos\",\n        \"5s\": \"5 segundos\",\n        \"10s\": \"10 segundos\",\n        \"20s\": \"20 segundos\",\n        \"30s\": \"30 segundos\",\n        \"1m\": \"1 minuto\",\n        \"2m\": \"2 minutos\"\n      },\n      \"autoPullOnOpen\": \"Puxar automaticamente ao abrir arquivos\",\n      \"autoPullOnOpenDesc\": \"Ao abrir um arquivo, puxa automaticamente a versão remota se for mais recente\",\n      \"autoPullOnSwitch\": \"Puxar automaticamente ao trocar de arquivo\",\n      \"autoPullOnSwitchDesc\": \"Ao trocar para outro arquivo, puxa automaticamente a versão remota se for mais recente\",\n      \"exclusions\": {\n        \"title\": \"Configuração de Exclusão de Sincronização\",\n        \"desc\": \"As seguintes configurações não serão sincronizadas entre dispositivos, pois são específicas do dispositivo\",\n        \"workspacePath\": \"Caminho do Workspace\",\n        \"workspaceHistory\": \"Histórico do Workspace\",\n        \"assetsPath\": \"Caminho dos Recursos\",\n        \"uiScale\": \"Escala da UI\",\n        \"contentTextScale\": \"Escala do Texto do Conteúdo\",\n        \"customCss\": \"CSS Personalizado\",\n        \"reason\": \"Essas configurações podem diferir entre dispositivos, excluí-las da sincronização evita erros de caminho e outros problemas\"\n      },\n      \"settingsSync\": {\n        \"uploadSuccess\": \"Configurações enviadas com sucesso\",\n        \"uploadFailed\": \"Falha ao enviar configurações\",\n        \"downloadSuccess\": \"Configurações baixadas com sucesso\",\n        \"downloadFailed\": \"Falha ao baixar configurações\",\n        \"autoSync\": \"As configurações serão sincronizadas automaticamente durante o upload/download (excluindo configurações específicas do dispositivo como o caminho do workspace)\"\n      },\n      \"jsdelivrSettingDesc\": \"Use jsDelivr para acelerar o acesso às imagens.\"\n    },\n    \"imageHosting\": {\n      \"title\": \"Hospedagem de Imagens\",\n      \"desc\": \"Aqui, você pode configurar serviços de hospedagem de imagens para armazenar e gerenciar suas imagens.\",\n      \"type\": \"Selecionar Plataforma\",\n      \"typeDesc\": \"Selecione o provedor de serviços de hospedagem de imagens\",\n      \"customRepoName\": \"Nome Personalizado do Repositório\",\n      \"customRepoNameDesc\": \"Deixe em branco para usar o nome do repositório padrão\",\n      \"isPrimaryBackup\": \"Método principal atual de hospedagem de imagens: {type}\",\n      \"setPrimaryBackup\": \"Definir como Hospedagem de Imagem Principal\",\n      \"smms\": {\n        \"token\": {\n          \"desc\": \"Por favor, crie e insira o Token SM.MS.\",\n          \"createToken\": \"Criar Token\"\n        },\n        \"disk\": \"Uso do Disco\",\n        \"error\": \"Falha ao obter, por favor, verifique a rede ou se o Token está correto.\"\n      },\n      \"picgo\": {\n        \"desc\": \"URL do servidor PicGo\",\n        \"ok\": \"O serviço está em execução, por favor, garanta que a hospedagem de imagens do PicGo esteja configurada.\",\n        \"error\": \"O serviço não está em execução, por favor, garanta que o PicGo (v2.2.0+) esteja em execução, caso contrário, o upload de imagens falhará.\"\n      },\n      \"github\": {\n        \"title\": \"Hospedagem de Imagens do GitHub\",\n        \"description\": \"Use o repositório do GitHub como serviço de armazenamento de imagens\",\n        \"repoStatus\": \"Status do Repositório\",\n        \"repoExists\": \"Repositório Existe\",\n        \"repoNotExists\": \"Repositório Não Encontrado\",\n        \"checking\": \"Verificando\",\n        \"creating\": \"Criando\",\n        \"manualCreateTitle\": \"Criação Manual de Repositório Necessária\",\n        \"manualCreateDesc\": \"Por favor, siga estes passos para criar o repositório de hospedagem de imagens:\",\n        \"createSteps\": {\n          \"step1\": \"Acesse o GitHub e faça login na sua conta\",\n          \"step2\": \"Clique no botão \\\"+\\\" no canto superior direito, selecione \\\"Novo repositório\\\"\",\n          \"step3\": \"Defina o nome do repositório como:\",\n          \"step4\": \"Opcionalmente, defina como repositório privado (recomendado)\",\n          \"step5\": \"Clique em \\\"Criar repositório\\\" para concluir a criação\",\n          \"step6\": \"Após a criação, clique no botão \\\"Verificar Novamente\\\" abaixo\"\n        },\n        \"createNewRepo\": \"Criar Novo Repositório\",\n        \"recheckRepo\": \"Verificar Novamente\",\n        \"recheckingRepo\": \"Verificando...\",\n        \"createRepo\": \"Criar Repositório\",\n        \"creatingRepo\": \"Criando...\"\n      },\n      \"s3\": {\n        \"title\": \"Armazenamento de Objetos S3\",\n        \"description\": \"Configure o AWS S3 ou serviço de armazenamento de objetos compatível com S3 como hospedagem de imagens\",\n        \"status\": \"Status da Conexão\",\n        \"connected\": \"Conectado\",\n        \"connecting\": \"Conectando\",\n        \"disconnected\": \"Desconectado\",\n        \"accessKeyId\": \"ID da Chave de Acesso\",\n        \"accessKeyIdPlaceholder\": \"Insira o ID da Chave de Acesso\",\n        \"secretAccessKey\": \"Chave de Acesso Secreta\",\n        \"secretAccessKeyPlaceholder\": \"Insira a Chave de Acesso Secreta\",\n        \"region\": \"Região\",\n        \"bucket\": \"Bucket\",\n        \"bucketPlaceholder\": \"Insira o nome do bucket\",\n        \"advancedSettings\": \"Configurações Avançadas\",\n        \"endpoint\": \"Endpoint Personalizado\",\n        \"endpointDesc\": \"Deixe em branco para AWS S3, ou insira o endpoint do serviço compatível com S3\",\n        \"customDomain\": \"Domínio Personalizado\",\n        \"customDomainDesc\": \"Opcional, domínio personalizado para acessar imagens\",\n        \"pathPrefix\": \"Prefixo do Caminho\",\n        \"pathPrefixDesc\": \"Opcional, prefixo do caminho para armazenamento de imagens\",\n        \"save\": \"Salvar Configuração\",\n        \"test\": \"Testar Conexão\",\n        \"setAsPrimary\": \"Definir como Principal\",\n        \"error\": \"Erro de Configuração\",\n        \"requiredFields\": \"Por favor, preencha os campos obrigatórios: ID da Chave de Acesso, Chave de Acesso Secreta, Região e Bucket\",\n        \"saveSuccess\": \"Configuração Salva\",\n        \"saveSuccessDesc\": \"A configuração do S3 foi salva\",\n        \"saveError\": \"Falha ao Salvar Configuração\",\n        \"testSuccess\": \"Teste de Conexão Bem-sucedido\",\n        \"testSuccessDesc\": \"A conexão S3 está funcionando, pronta para enviar imagens\",\n        \"testFailed\": \"Falha no Teste de Conexão\",\n        \"testFailedDesc\": \"Por favor, verifique a configuração e a conexão de rede\",\n        \"testFirstDesc\": \"Por favor, teste a conexão com sucesso antes de definir como principal\",\n        \"setPrimarySuccess\": \"Definido com Sucesso\",\n        \"setPrimarySuccessDesc\": \"O S3 foi definido como a hospedagem de imagem principal\"\n      }\n    },\n    \"imageMethod\": {\n      \"title\": \"Reconhecimento de Imagem\",\n      \"desc\": \"Aqui, você pode configurar definições relacionadas ao reconhecimento de imagem, suportando OCR e VLM de duas maneiras.\",\n      \"setPrimary\": \"Definir como padrão\",\n      \"isPrimary\": \"{type} foi definido como padrão\",\n      \"ocr\": {\n        \"title\": \"OCR\",\n        \"languagePacks\": \"Pacote de Idioma\",\n        \"checkModels\": \"Aqui você pode pesquisar todos os modelos\",\n        \"modelInstruction\": \"Separados por vírgula, por exemplo: eng,chi_sim\"\n      },\n      \"vlm\": {\n        \"title\": \"Modelo de Linguagem Visual\",\n        \"desc\": \"Use o modelo de linguagem visual para reconhecer o conteúdo da imagem.\"\n      },\n      \"enable\": {\n        \"title\": \"Ativar reconhecimento de imagens\",\n        \"desc\": \"Quando ativado, ao registrar uma captura de tela ou inserir uma imagem, o reconhecimento será feito automaticamente. Quando desativado, a etapa de reconhecimento de imagem será ignorada.\"\n      }\n    },\n    \"backupSync\": {\n      \"title\": \"Dados de Backup\",\n      \"desc\": \"Aqui, você pode usar outros métodos para fazer backup de seus dados, você pode fazer backup regularmente para garantir a segurança dos dados.\",\n      \"localBackup\": {\n        \"tabTitle\": \"Backup Local\",\n        \"export\": {\n          \"title\": \"Exportar Backup\",\n          \"desc\": \"Empacote os dados do aplicativo em um arquivo .zip e salve no local especificado.\",\n          \"button\": \"Escolher Local e Exportar\",\n          \"simpleButton\": \"Exportar\",\n          \"exporting\": \"Exportando...\"\n        },\n        \"import\": {\n          \"title\": \"Importar Backup\",\n          \"desc\": \"Restaure os dados do aplicativo a partir de um arquivo .zip, irá sobrescrever todos os dados atuais.\",\n          \"button\": \"Escolher Arquivo e Importar\",\n          \"importing\": \"Importando...\",\n          \"warning\": \"A operação de importação sobrescreverá todos os dados atuais, por favor, garanta que o conteúdo importante esteja salvo!\"\n        },\n        \"exportDialog\": {\n          \"title\": \"Escolha o local para salvar o arquivo de backup\"\n        },\n        \"importDialog\": {\n          \"title\": \"Escolha o arquivo de backup para importar\"\n        },\n        \"exportSuccess\": \"Backup exportado com sucesso!\",\n        \"exportError\": \"Falha na exportação do backup\",\n        \"importSuccess\": \"Backup importado com sucesso! O aplicativo será reiniciado para aplicar as alterações.\",\n        \"importError\": \"Falha na importação do backup\",\n        \"restartConfirm\": \"Importação concluída! Reiniciar o aplicativo agora para aplicar as alterações?\"\n      }\n    },\n    \"template\": {\n      \"title\": \"Modelo\",\n      \"desc\": \"Aqui você pode criar e gerenciar templates de organização personalizados para ajudar a IA a organizar o conteúdo dos registros de acordo com suas necessidades.\",\n      \"customTemplate\": \"Template Personalizado\",\n      \"addTemplate\": \"Adicionar Template Personalizado\",\n      \"deleteConfirm\": \"Tem certeza de que deseja excluir este template?\",\n      \"status\": \"Estado\",\n      \"name\": \"Nome\",\n      \"content\": \"Conteúdo\",\n      \"scope\": \"Escopo\",\n      \"selectScope\": \"Selecionar Escopo\",\n      \"addTemplateDesc\": \"Por favor, insira o nome e o conteúdo do template personalizado, ajudando a IA a entender melhor suas necessidades.\",\n      \"editTemplate\": \"Editar Template Personalizado\",\n      \"noContent\": \"Nenhum conteúdo\",\n      \"range\": {\n        \"all\": \"Todos\",\n        \"today\": \"Hoje\",\n        \"week\": \"Semana Passada\",\n        \"month\": \"Mês Passado\",\n        \"threeMonth\": \"Últimos 3 Meses\",\n        \"year\": \"Último Ano\"\n      }\n    },\n    \"shortcut\": {\n      \"title\": \"Atalhos\",\n      \"screenshot\": \"Registro por Captura de Tela\",\n      \"link\": \"Registro por Link\",\n      \"textRecord\": \"Registro por Texto\",\n      \"windowPin\": \"Fixar Janela\"\n    },\n    \"theme\": {\n      \"title\": \"Aparência\",\n      \"appTheme\": \"Tema do aplicativo\",\n      \"previewTheme\": \"Tema do conteúdo de visualização\",\n      \"codeTheme\": \"Tema de destaque do bloco de código\",\n      \"selectTheme\": \"Selecionar Tema\"\n    },\n    \"chat\": {\n      \"title\": \"Configurações de Chat\",\n      \"desc\": \"Configure as configurações relacionadas ao chat aqui, incluindo geração de resumo.\",\n      \"primaryModel\": {\n        \"title\": \"Modelo Principal\",\n        \"model\": {\n          \"title\": \"Modelo de Chat Principal\",\n          \"desc\": \"Selecione o modelo de IA principal para conversas diárias\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"Configurações da Barra de Ferramentas\",\n        \"chatToolbar\": {\n          \"title\": \"Barra de Ferramentas de Chat\",\n          \"desc\": \"Personalize a visibilidade e a ordem dos botões da barra de ferramentas de chat\",\n          \"button\": \"Configurar\",\n          \"modelSelect\": {\n            \"desc\": \"Alternar o modelo de IA para conversa\"\n          },\n          \"promptSelect\": {\n            \"desc\": \"Selecionar o prompt predefinido para a conversa\"\n          },\n          \"chatLanguage\": {\n            \"desc\": \"Definir o idioma da conversa\"\n          },\n          \"chatLink\": {\n            \"title\": \"Vincular Etiqueta\",\n            \"desc\": \"Vincular conteúdo de notas da etiqueta atual ao contexto da conversa\"\n          },\n          \"fileLink\": {\n            \"desc\": \"Vincular arquivos ou pastas ao contexto da conversa\"\n          },\n          \"mcpButton\": {\n            \"desc\": \"Selecionar e conectar servidores MCP para usar ferramentas externas\"\n          },\n          \"ragSwitch\": {\n            \"title\": \"Base de Conhecimento\",\n            \"desc\": \"Ativar busca na base de conhecimento vetorial\"\n          },\n          \"clipboardMonitor\": {\n            \"title\": \"Monitorar Área de Transferência\",\n            \"desc\": \"Monitorar automaticamente alterações no conteúdo da área de transferência\"\n          },\n          \"newChat\": {\n            \"desc\": \"Iniciar nova conversa\"\n          },\n          \"clearContext\": {\n            \"desc\": \"Limpar contexto da conversa, manter histórico\"\n          },\n          \"clearChat\": {\n            \"desc\": \"Excluir todos os registros de chat\"\n          }\n        }\n      },\n      \"condense\": {\n        \"title\": \"Resumo da Conversa\",\n        \"enable\": {\n          \"title\": \"Ativar Resumo\",\n          \"desc\": \"Comprimir automaticamente conversas longas para economizar uso de tokens\"\n        },\n        \"model\": {\n          \"title\": \"Modelo de Resumo\",\n          \"desc\": \"Selecione o modelo de IA para gerar resumos\",\n          \"placeholder\": \"Usar modelo principal\"\n        },\n        \"threshold\": {\n          \"title\": \"Limiar de Gatilho\",\n          \"desc\": \"Verificar compressão quando mensagens da IA excederem esta contagem\"\n        },\n        \"minToken\": {\n          \"title\": \"Contagem Mínima de Tokens\",\n          \"desc\": \"Comprimir apenas mensagens que excedam esta contagem de tokens\"\n        },\n        \"keepLatest\": {\n          \"title\": \"Manter Mais Recentes\",\n          \"desc\": \"Manter as últimas N mensagens da IA não compactadas\"\n        },\n        \"maxLength\": {\n          \"title\": \"Comprimento Máximo do Resumo\",\n          \"desc\": \"Controlar a contagem máxima de palavras dos resumos gerados\"\n        },\n        \"prompt\": {\n          \"title\": \"Prompt de Resumo Personalizado\",\n          \"desc\": \"Personalize o modelo de prompt para gerar resumos\",\n          \"label\": \"Modelo de Prompt\",\n          \"placeholder\": \"Digite o prompt personalizado...\",\n          \"help\": \"Use {content} como um espaço reservado para o conteúdo original\",\n          \"save\": \"Salvar\",\n          \"reset\": \"Redefinir para o Padrão\"\n        }\n      },\n      \"inspiration\": {\n        \"title\": \"Modelo de Inspiração\",\n        \"model\": {\n          \"title\": \"Gerador de Sugestões Rápidas\",\n          \"desc\": \"Gere sugestões de prompt rápidas para ajudar os usuários a iniciar conversas\"\n        }\n      },\n      \"conversationTitle\": {\n        \"title\": \"会话标题\",\n        \"model\": {\n          \"title\": \"标题生成模型\",\n          \"desc\": \"选择用于生成会话标题的 AI 模型\"\n        }\n      }\n    },\n    \"dev\": {\n      \"title\": \"Desenvolvedor\",\n      \"desc\": \"Aqui você pode configurar opções de desenvolvedor, incluindo proxy de rede, limpeza de dados e gerenciamento de arquivos de configuração.\",\n      \"clearData\": \"Limpar Dados\",\n      \"clearDataConfirm\": \"Tem certeza de que deseja limpar os dados?\",\n      \"proxy\": \"Proxy, usado para resolver problemas de rede, após a configuração, é recomendado reiniciar o aplicativo.\",\n      \"proxyPlaceholder\": \"Insira o endereço do proxy\",\n      \"proxyTitle\": \"Proxy de Rede\",\n      \"clearDataTitle\": \"Limpar Dados\",\n      \"clearDataDesc\": \"Limpar informações de dados, incluindo configuração do sistema e banco de dados (incluindo registros).\",\n      \"clearFileTitle\": \"Limpar Arquivos\",\n      \"clearFileDesc\": \"Limpar arquivos, incluindo imagens e artigos.\",\n      \"clearButton\": \"Limpar\",\n      \"configFileTitle\": \"Gerenciamento de Arquivo de Configuração\",\n      \"configFileDesc\": \"Importar e exportar arquivos de configuração. A importação sobrescreverá a configuração atual e terá efeito após a reinicialização.\",\n      \"importConfigTitle\": \"Importar Arquivo de Configuração\",\n      \"exportConfigTitle\": \"Exportar Arquivo de Configuração\",\n      \"importConfigSuccessMobile\": \"Configuração baixada com sucesso, por favor, reinicie o aplicativo manualmente\",\n      \"exportConfigSuccess\": \"Exportação bem-sucedida\",\n      \"importButton\": \"Importar\",\n      \"exportButton\": \"Exportar\"\n    },\n    \"ai\": {\n      \"title\": \"Gerenciamento de Modelos\",\n      \"desc\": \"Aqui, você pode adicionar e gerenciar vários serviços de modelo personalizados. Após a configuração, você desbloqueará recursos relacionados à IA, como funções de organização e conversação.\",\n      \"modelTitle\": \"Nome Personalizado\",\n      \"modelConfigTitle\": \"Configuração do Modelo\",\n      \"modelConfigDesc\": \"Cada configuração corresponde a um modelo de IA, você pode criar novas configurações através de templates ou personalizadas.\",\n      \"providerInfo\": \"Informações do Provedor\",\n      \"providerInfoDesc\": \"Esta configuração é criada com base em um template de provedor, com nome e URL pré-configurados.\",\n      \"create\": \"Criar\",\n      \"createDesc\": \"Selecione uma configuração vazia ou crie uma nova configuração usando o template do fornecedor.\",\n      \"createSection\": {\n        \"title\": \"Configuração de Modelo Personalizado\",\n        \"descWithoutModels\": \"Adicione configurações de modelo de IA personalizadas para usar serviços de modelo mais poderosos.\"\n      },\n      \"config\": \"Configurar\",\n      \"custom\": \"Personalizado\",\n      \"addCustomModel\": \"Personalizado\",\n      \"deleteCustomModel\": \"Excluir\",\n      \"deleteCustomModelConfirm\": \"Tem certeza de que deseja excluir este modelo personalizado?\",\n      \"copyConfig\": \"Copiar\",\n      \"builtin\": \"Embutido\",\n      \"modelSupport\": \"Suporta apenas modelos de IA com protocolo OpenAI\",\n      \"apiKeyUrl\": \"Criar Chave de API\",\n      \"modelType\": {\n        \"title\": \"Tipo de Modelo\",\n        \"desc\": \"Selecione o tipo de modelo de IA com base em sua capacidade\",\n        \"chat\": \"Bate-papo\",\n        \"image\": \"Imagem\",\n        \"video\": \"Vídeo\",\n        \"tts\": \"Texto para Fala (TTS)\",\n        \"stt\": \"Fala para Texto (STT)\",\n        \"embedding\": \"Embedding\",\n        \"rerank\": \"Reordenação\",\n        \"audio\": \"Áudio\"\n      },\n      \"modelList\": {\n        \"error\": {\n          \"title\": \"Falha ao obter lista de modelos\",\n          \"description\": \"Por favor, verifique se a Chave de API ou a rede estão corretas\"\n        }\n      },\n      \"selectModel\": \"Por favor, selecione um modelo\",\n      \"modelProviderDesc\": \"Modelos personalizados suportam apenas modelos de IA com protocolo OpenAI.\",\n      \"modelTitleDesc\": \"Nome personalizado, usado para identificar modelos de IA, por favor, não repita.\",\n      \"modelBaseUrlDesc\": \"Você só precisa configurar o número da versão, por exemplo: https://api.openai.com/v1, o sufixo será adicionado automaticamente.\",\n      \"modelDesc\": \"Alguns modelos suportam a obtenção da lista de modelos, se não for suportado, por favor, configure manualmente.\",\n      \"temperatureDesc\": \"Controla a aleatoriedade da saída. Valores mais baixos tornam o conteúdo gerado mais determinístico.\",\n      \"topPDesc\": \"Um método de amostragem nucleus, onde o modelo considera os resultados dos tokens com massa de probabilidade top_p. Então, 0.1 significa considerar apenas os 10% superiores da massa de probabilidade. Geralmente, sugerimos alterar este ou a temperatura, mas não ambos.\",\n      \"customHeaders\": \"Cabeçalhos Personalizados\",\n      \"customHeadersDesc\": \"Adicione cabeçalhos HTTP personalizados com pares chave-valor.\",\n      \"headerKey\": \"Chave\",\n      \"headerValue\": \"Valor\",\n      \"addHeader\": \"Adicionar Cabeçalho\",\n      \"connectionSuccess\": \"Teste de conexão com IA bem-sucedido\",\n      \"voice\": \"Tipo de Voz\",\n      \"voiceDesc\": \"Especifique o tipo de voz para modelos de áudio, como 'alloy', 'echo', 'fable', etc.\",\n      \"voicePlaceholder\": \"Insira o tipo de voz, ex: alloy\",\n      \"defaultModels\": {\n        \"title\": \"Modelos Gratuitos Padrão\",\n        \"desc\": \"O NoteGen fornece serviços de modelo de IA gratuitos para usuários, permitindo funcionalidade básica sem configuração.\",\n        \"chatModel\": {\n          \"name\": \"Qwen/Qwen3-8B\",\n          \"type\": \"Modelo de Chat\",\n          \"desc\": \"Adequado para conversas diárias e geração de texto\"\n        },\n        \"embeddingModel\": {\n          \"name\": \"BAAI/bge-m3\",\n          \"type\": \"Modelo de Embedding\",\n          \"desc\": \"Usado para vetorização de texto e busca semântica\"\n        },\n        \"visionModel\": {\n          \"name\": \"OpenGVLab/InternVL2-8B\",\n          \"type\": \"Modelo de Visão\",\n          \"desc\": \"Suporta compreensão de imagens e perguntas visuais\"\n        },\n        \"completionModel\": {\n          \"name\": \"Conclusão Rápida\",\n          \"type\": \"Modelo de Conclusão\",\n          \"desc\": \"Conclusão inline de IA para editor Markdown, semelhante ao GitHub Copilot, gera rapidamente conteúdo de continuação\"\n        },\n        \"commit\": {\n          \"title\": \"Mensagem de Commit\",\n          \"desc\": \"Usado para gerar automaticamente mensagens de commit Git, gerando inteligentemente mensagens de commit descritivas com base nas alterações do conteúdo do arquivo.\"\n        },\n        \"poweredBy\": \"Fornecido por SiliconFlow\"\n      },\n      \"connectionFailed\": \"Falha na conexão\",\n      \"enableStream\": \"Resposta em streaming\",\n      \"enableStreamDesc\": \"Ativar resposta em streaming permite mostrar o conteúdo gerado em tempo real, mas alguns modelos podem não oferecer suporte a este recurso.\",\n      \"selectConfig\": \"Selecione uma configuração\",\n      \"models\": \"Lista de modelos\",\n      \"modelsDesc\": \"Gerencie aqui todos os modelos da configuração atual. Cada modelo pode ter tipos e parâmetros diferentes.\",\n      \"addModel\": \"Adicionar modelo\",\n      \"newModel\": \"Novo modelo\",\n      \"checkConnection\": \"Testar conexão\",\n      \"model\": \"Modelo\"\n    },\n    \"ocr\": {\n      \"title\": \"OCR\",\n      \"languagePacks\": \"Pacotes de Idioma\",\n      \"checkModels\": \"Verifique todos os modelos aqui\",\n      \"modelInstruction\": \"Separe com vírgulas, ex: eng,chi_sim\"\n    },\n    \"file\": {\n      \"title\": \"Configurações de Arquivo\",\n      \"desc\": \"Aqui, você pode gerenciar as configurações do workspace e outras opções relacionadas a arquivos.\",\n      \"workspace\": {\n        \"title\": \"Configurações do Workspace\",\n        \"desc\": \"Defina o diretório do workspace do aplicativo onde os arquivos serão salvos\",\n        \"current\": \"Caminho Atual do Workspace\",\n        \"defaultPath\": \"Workspace Padrão\",\n        \"default\": \"Usando caminho do workspace padrão\",\n        \"custom\": \"Usando caminho do workspace personalizado\",\n        \"select\": \"Selecionar Diretório do Workspace\",\n        \"reset\": \"Redefinir para Caminho Padrão\",\n        \"history\": \"Caminhos Históricos\",\n        \"selectFromHistory\": \"Selecionar workspace do histórico\",\n        \"clearHistory\": \"Limpar Histórico\",\n        \"actions\": \"Ações\",\n        \"searchPlaceholder\": \"Buscar caminhos do workspace...\",\n        \"noResults\": \"Nenhum resultado encontrado\"\n      },\n      \"info\": {\n        \"title\": \"Informações do Workspace\",\n        \"desc\": \"Após alterar o workspace, você precisa reiniciar o aplicativo para que as alterações tenham efeito total. Os arquivos no novo workspace serão exibidos após a reinicialização.\"\n      },\n      \"toast\": {\n        \"updated\": \"Workspace Atualizado\",\n        \"updatedDesc\": \"Workspace definido para: {path}\",\n        \"reset\": \"Workspace Redefinido\",\n        \"resetDesc\": \"Restaurado para o workspace padrão\",\n        \"error\": \"Falha na Seleção do Workspace\",\n        \"errorDesc\": \"Não foi possível selecionar o diretório do workspace, por favor, tente novamente\",\n        \"resetError\": \"Falha na Redefinição do Workspace\",\n        \"resetErrorDesc\": \"Não foi possível redefinir para o workspace padrão, por favor, tente novamente\"\n      },\n      \"assets\": {\n        \"title\": \"Caminho dos Recursos\",\n        \"desc\": \"Defina o caminho onde os recursos (ex: imagens, vídeos, arquivos, etc.) usados na escrita serão salvos. Os recursos serão salvos no mesmo nível do arquivo markdown atualmente editado.\",\n        \"select\": \"Defina o caminho onde os recursos usados na escrita serão salvos\"\n      }\n    },\n    \"shortcuts\": {\n      \"title\": \"Atalhos\",\n      \"desc\": \"Aqui, você pode configurar atalhos para ajudá-lo a usar o NoteGen com mais eficiência.\",\n      \"resetDefaults\": \"Redefinir\",\n      \"clear\": \"Limpar\",\n      \"noShortcut\": \"Nenhum Atalho\",\n      \"shortcuts\": {\n        \"openWindow\": {\n          \"title\": \"Abrir/Ocultar Janela\",\n          \"desc\": \"Abrir/Ocultar a janela principal.\"\n        },\n        \"quickRecordText\": {\n          \"title\": \"Gravação Rápida de Texto\",\n          \"desc\": \"Abrir rapidamente a janela principal e alternar para a gravação de texto.\"\n        }\n      }\n    },\n    \"skills\": {\n      \"title\": \"Skills\",\n      \"desc\": \"Skills são pacotes reutilizáveis de capacidades de IA, permitindo que o assistente aplique automaticamente comportamentos específicos conforme a tarefa.\",\n      \"enable\": \"Ativar Skills\",\n      \"enableDesc\": \"Quando ativado, a IA pode usar as Skills configuradas.\",\n      \"autoMatch\": \"Combinar Skills automaticamente\",\n      \"autoMatchDesc\": \"Seleciona automaticamente Skills adequadas com base no que você digitar.\",\n      \"project\": \"Skills do workspace\",\n      \"global\": \"Skills globais\",\n      \"globalPath\": \"Local de armazenamento das Skills globais\",\n      \"openInFileManager\": \"Abrir no gerenciador de arquivos\",\n      \"createSkill\": \"Criar Skill\",\n      \"editSkill\": \"Editar Skill\",\n      \"deleteSkill\": \"Excluir Skill\",\n      \"exportSkill\": \"Exportar Skill\",\n      \"importSkill\": \"Importar Skill\",\n      \"selectSkillZip\": \"Selecionar arquivo .zip de Skill\",\n      \"importHelp\": \"Suporta importar Skill em formato .zip. O arquivo .zip precisa conter um arquivo SKILL.md.\",\n      \"importing\": \"Importando...\",\n      \"imported\": \"Importado\",\n      \"importSuccess\": \"Importado com sucesso\",\n      \"importError\": \"Falha ao importar\",\n      \"noSkills\": \"Ainda não há Skills\",\n      \"noSkillsDesc\": \"Crie ou importe Skills para começar a usar.\",\n      \"noSkillsGlobal\": \"Ainda não há Skills globais\",\n      \"noSkillsGlobalDesc\": \"Crie ou importe Skills para usar em todos os projetos.\",\n      \"emptyWorkspace\": \"Não há Skills neste workspace\",\n      \"emptyWorkspaceDesc\": \"Crie um arquivo SKILL.md na pasta skills para adicionar uma Skill.\",\n      \"installedGlobalSkills\": \"Skills globais instaladas\",\n      \"basicSettings\": \"Configurações básicas\",\n      \"metadata\": \"Metadados\",\n      \"skillName\": \"Nome da Skill\",\n      \"skillDescription\": \"Descrição\",\n      \"skillVersion\": \"Versão\",\n      \"skillAuthor\": \"Autor\",\n      \"nameRequired\": \"Informe o nome da Skill\",\n      \"descriptionRequired\": \"Informe a descrição\",\n      \"namePlaceholder\": \"note-organizer\",\n      \"versionPlaceholder\": \"1.0.0\",\n      \"descriptionPlaceholder\": \"Organiza e otimiza automaticamente a estrutura das notas...\",\n      \"authorPlaceholder\": \"Seu nome\",\n      \"descriptionHelp\": \"Usado para a IA combinar; descreva a função desta Skill e quando ela deve ser aplicada.\",\n      \"allowedTools\": \"Ferramentas permitidas\",\n      \"allowedToolsHelp\": \"Essas ferramentas podem ser usadas sem confirmação do usuário.\",\n      \"userInvocable\": \"Mostrar no menu de barra (/)\",\n      \"userInvocableHelp\": \"O usuário pode acionar manualmente via /nome-da-skill\",\n      \"instructions\": \"Conteúdo das instruções\",\n      \"instructionsHelp\": \"Instruções detalhadas para a IA (suporta Markdown).\",\n      \"instructionsPlaceholder\": \"Digite instruções detalhadas para a IA...\",\n      \"content\": \"Conteúdo das instruções\",\n      \"deleteSkillTitle\": \"Excluir Skill\",\n      \"deleteSkillDesc\": \"Tem certeza de que deseja excluir esta Skill? Esta ação não pode ser desfeita.\",\n      \"skillDeleted\": \"Skill excluída com sucesso\"\n    },\n    \"readAloud\": {\n      \"title\": \"Leitura em voz alta\",\n      \"desc\": \"Aqui você pode configurar opções de leitura em voz alta para reproduzir o conteúdo do chat em áudio.\",\n      \"noModel\": \"Não usar modelo\",\n      \"alert\": {\n        \"title\": \"Você está usando a leitura do sistema\",\n        \"description\": \"Nenhum modelo de áudio foi configurado. Usando a leitura do próprio sistema.\"\n      },\n      \"options\": {\n        \"audioModel\": {\n          \"title\": \"Modelo de áudio\",\n          \"desc\": \"Escolha o modelo de IA para texto-para-fala (TTS). Suporta diferentes vozes e parâmetros.\"\n        },\n        \"speed\": {\n          \"title\": \"Velocidade\",\n          \"desc\": \"Ajuste a velocidade da voz (de 0,25× a 4×). O padrão é 1×.\"\n        }\n      }\n    }\n  },\n  \"record\": {\n    \"trash\": {\n      \"title\": \"Esvaziar Lixeira\",\n      \"confirm\": \"Tem certeza de que deseja esvaziar a lixeira?\",\n      \"records\": \"{count} registros podem ser restaurados\",\n      \"empty\": \"Esvaziar\",\n      \"close\": \"Fechar Lixeira\"\n    },\n    \"queue\": {\n      \"ocr\": \"Reconhecimento OCR\",\n      \"ai\": \"Reconhecimento de conteúdo por IA\",\n      \"upload\": \"Upload para host de imagem\",\n      \"jsdelivr\": \"Notificar cache jsdelivr\",\n      \"recording\": \"Gravando...\",\n      \"recorded\": \"Gravado\",\n      \"record\": \"Gravar\",\n      \"detected\": \"Detectado\",\n      \"save\": \"Salvar\"\n    },\n    \"mark\": {\n      \"empty\": \"Nenhum registro ainda\",\n      \"loading\": \"Carregando...\",\n      \"createdAt\": \"Criado em\",\n      \"type\": {\n        \"scan\": \"Digitalização\",\n        \"image\": \"Imagem\",\n        \"screenshot\": \"Captura de Tela\",\n        \"text\": \"Texto\",\n        \"recording\": \"Gravação\",\n        \"file\": \"Arquivo\",\n        \"link\": \"Vínculo\",\n        \"todo\": \"Todo\",\n        \"pdf\": \"PDF\",\n        \"upload\": \"Upload de Registro\",\n        \"download\": \"Download de Registro\",\n        \"uploadTo\": \"Sincronizar do local para {provider}\",\n        \"downloadFrom\": \"Sincronizar de {provider} para local\"\n      },\n      \"uploadSuccess\": \"Upload de registro bem-sucedido\",\n      \"downloadSuccess\": \"Download de registro bem-sucedido\",\n      \"desc\": \"Descrição\",\n      \"content\": \"Conteúdo\",\n      \"progress\": {\n        \"cacheImage\": \"Armazenando imagem em cache\",\n        \"ocr\": \"Reconhecimento OCR\",\n        \"aiAnalysis\": \"Análise de conteúdo por IA\",\n        \"uploadImage\": \"Enviando para host de imagem\",\n        \"jsdelivrCache\": \"Notificando cache jsdelivr\",\n        \"cacheFile\": \"Armazenando arquivo em cache\",\n        \"cacheScreenshot\": \"Armazenando captura de tela em cache\",\n        \"textAnalysis\": \"Análise de texto\",\n        \"save\": \"Salvando\",\n        \"saveImage\": \"Salvando imagem\",\n        \"newToken\": \"Criar token de acesso\",\n        \"newTokenDesc\": \"O novo token deve ser configurado com permissão de 'repo', a configuração criará automaticamente um repositório de arquivos (privado) e um repositório de imagens.\"\n      },\n      \"text\": {\n        \"title\": \"Gravar Texto\",\n        \"description\": \"Grave um pedaço de texto, que será inserido na posição apropriada ao organizar notas.\",\n        \"characterCount\": \"{count} caracteres\",\n        \"save\": \"Salvar\",\n        \"autoReadClipboard\": \"Ler texto da área de transferência automaticamente\"\n      },\n      \"link\": {\n        \"title\": \"Gravar Link\",\n        \"description\": \"Insira um link de página da web, e o sistema rastreará automaticamente o conteúdo da página e o salvará\",\n        \"save\": \"Salvar\",\n        \"autoReadClipboard\": \"Ler link da área de transferência automaticamente\"\n      },\n      \"todo\": {\n        \"title\": \"Registro Todo\",\n        \"description\": \"Crie itens todo para gerenciar suas tarefas\",\n        \"titlePlaceholder\": \"Digite o título do todo...\",\n        \"descriptionPlaceholder\": \"Digite descrição detalhada (opcional)\",\n        \"priority\": \"Prioridade\",\n        \"priorityLow\": \"Baixa\",\n        \"priorityMedium\": \"Média\",\n        \"priorityHigh\": \"Alta\",\n        \"dateRange\": \"Intervalo de Datas\",\n        \"dateRangePlaceholder\": \"Selecione o intervalo de datas\",\n        \"dueDate\": \"Data de Vencimento\",\n        \"dueDatePlaceholder\": \"Selecione a data\",\n        \"save\": \"Criar Todo\",\n        \"saveEdit\": \"Salvar\",\n        \"edit\": \"Editar Todo\",\n        \"editDescription\": \"Modificar os detalhes do item todo\",\n        \"cancel\": \"Cancelar\",\n        \"selectTag\": \"Selecionar Tag\",\n        \"completed\": \"Concluído\",\n        \"uncompleted\": \"Não Concluído\"\n      },\n      \"clipboard\": {\n        \"detectedImage\": \"Imagem da área de transferência detectada\",\n        \"detectedText\": \"Texto da área de transferência detectado\"\n      },\n      \"tag\": {\n        \"searchPlaceholder\": \"Crie ou pesquise tags...\",\n        \"noResults\": \"Nenhuma tag correspondente encontrada\",\n        \"quickAdd\": \"Criação Rápida\",\n        \"pinned\": \"Fixadas\",\n        \"others\": \"Outras\",\n        \"rename\": \"Renomear\",\n        \"delete\": \"Excluir\",\n        \"pin\": \"Fixar\",\n        \"unpin\": \"Desafixar\",\n        \"newTag\": \"Nova Tag\",\n        \"newTagPlaceholder\": \"Insira o nome da tag...\",\n        \"add\": \"Adicionar\"\n      },\n      \"mark\": {\n        \"empty\": \"Nenhum registro\",\n        \"emptyHint\": \"Use a barra de ferramentas no topo para criar seu primeiro registro!\",\n        \"type\": {\n          \"text\": \"Texto\"\n        },\n        \"chat\": {\n          \"modeSelect\": {\n            \"chat\": \"Chat\",\n            \"agent\": \"Agente\"\n          },\n          \"agent\": {\n            \"running\": \"Agente em Execução\",\n            \"thinking\": \"Pensando\",\n            \"acting\": \"Agindo\",\n            \"observation\": \"Observação\",\n            \"toolCalls\": \"Chamadas de Ferramenta\",\n            \"thought\": \"Pensamento\",\n            \"action\": \"Ação\",\n            \"confirmation\": {\n              \"title\": \"Confirmar Ação\",\n              \"description\": \"O agente deseja executar a seguinte ação. Por favor, confirme para continuar.\",\n              \"tool\": \"Ferramenta\",\n              \"parameters\": \"Parâmetros\",\n              \"cancel\": \"Cancelar\",\n              \"confirm\": \"Confirmar\",\n              \"confirmed\": \"Confirmado\",\n              \"cancelled\": \"Cancelado\"\n            }\n          },\n          \"placeholder\": {\n            \"default\": \"Faça perguntas ou organize suas notas em um artigo...\",\n            \"noApiKey\": \"Chave de API não configurada, recurso de chat com IA indisponível...\",\n            \"on\": \"Sugestão da IA: ligado\",\n            \"off\": \"Sugestão da IA: desligado\"\n          },\n          \"header\": {\n            \"configApiKey\": \"Configurar CHAVE DE API\",\n            \"clearChat\": \"Limpar Chat\",\n            \"configPrompt\": \"Configurar Prompt\",\n            \"selectPrompt\": \"Selecionar Prompt\"\n          },\n          \"clipboard\": {\n            \"image\": {\n              \"detected\": \"Imagem detectada na área de transferência:\",\n              \"recording\": \"Gravando...\",\n              \"recorded\": \"Gravado\",\n              \"record\": \"Gravar\"\n            },\n            \"text\": {\n              \"detected\": \"Texto detectado na área de transferência:\",\n              \"recorded\": \"Gravado\",\n              \"record\": \"Gravar\"\n            }\n          },\n          \"messageControl\": {\n            \"words\": \"palavras\",\n            \"summary\": \"Resumo\"\n          },\n          \"mcp\": {\n            \"maxIterationsReached\": \"Número máximo de iterações de chamada de ferramenta atingido\",\n            \"toolCall\": \"Servidor MCP\",\n            \"params\": \"Parâmetros\",\n            \"result\": \"Resultado\",\n            \"copy\": \"Copiar\",\n            \"paramsCopied\": \"Parâmetros copiados\",\n            \"resultCopied\": \"Resultado copiado\",\n            \"calling\": \"Chamando\",\n            \"success\": \"Concluído\",\n            \"error\": \"Falha\"\n          },\n          \"empty\": {\n            \"title\": \"Iniciar Conversa com IA\",\n            \"subtitle\": \"Use o modo Chat ou Agent para interagir com IA\",\n            \"currentModel\": \"Modelo Atual\",\n            \"currentPrompt\": \"Prompt Atual\",\n            \"currentMode\": \"Modo de Conversa\",\n            \"noModel\": \"Nenhum modelo definido\",\n            \"noPrompt\": \"Nenhum prompt definido\",\n            \"configureModel\": \"Configurar Modelo\",\n            \"modeHint\": \"Toque no botão à esquerda do campo de entrada\",\n            \"modeHintSuffix\": \"para alternar o modo de conversa\",\n            \"recentConversations\": \"Conversas Recentes\",\n            \"deleteConversation\": \"Excluir conversa\",\n            \"conversationHistory\": \"Histórico\",\n            \"viewMore\": \"Ver mais\",\n            \"messages\": \"mensagens\",\n            \"searchPlaceholder\": \"Pesquisar conversas...\",\n            \"noMatchingConversations\": \"Nenhuma conversa correspondente encontrada\",\n            \"noConversationHistory\": \"Sem histórico de conversa\",\n            \"quickPrompts\": {\n              \"title\": \"Início Rápido\",\n              \"writeNote\": \"Ajude-me a escrever uma nota\",\n              \"summarize\": \"Ajude-me a resumir este conteúdo\",\n              \"brainstorm\": \"Ajude-me a brainstormar ideias\",\n              \"explain\": \"Ajude-me a explicar este conceito\"\n            }\n          },\n          \"content\": {\n            \"organize\": \"Organize seus registros em um artigo:\"\n          },\n          \"note\": {\n            \"writing\": \"Escrever\",\n            \"convert\": \"Converter Artigo\",\n            \"description\": \"A nota atual é gerada por IA e não pode ser editada. Converta a nota atual em um artigo (gere um arquivo local) para edição posterior na página de escrita.\",\n            \"filename\": \"Nome do Arquivo\",\n            \"selectFolder\": \"Selecionar pasta\",\n            \"rootDirectory\": \"Diretório raiz\",\n            \"deleteTag\": \"Excluir tag atual, registros e notas (pode ser restaurado da lixeira)\",\n            \"warning\": \"Após a conversão, você será redirecionado para a página de escrita.\",\n            \"convert_button\": \"Converter\",\n            \"organizeAs\": \"Organize seus registros em um artigo:\",\n            \"templateContent\": \"Conteúdo do template\",\n            \"recordRange\": \"Intervalo de registros\",\n            \"filterThinkingContent\": \"Remover conteúdo de 'pensamento' dos registros\",\n            \"startOrganize\": \"Começar a organizar\",\n            \"manageTemplate\": \"Gerenciar template\",\n            \"cancel\": \"Cancelar\"\n          },\n          \"mark\": {\n            \"recorded\": \"Gravado\",\n            \"record\": \"Gravar\"\n          },\n          \"send\": \"Enviar\"\n        },\n        \"text\": {\n          \"title\": \"Gravar Texto\",\n          \"description\": \"Grave um pedaço de texto, que será inserido na posição apropriada ao organizar notas.\",\n          \"characterCount\": \"{count} caracteres\",\n          \"save\": \"Salvar\"\n        },\n        \"clipboard\": {\n          \"detectedImage\": \"Imagem da área de transferência detectada\",\n          \"detectedText\": \"Texto da área de transferência detectado\"\n        },\n        \"tag\": {\n          \"searchPlaceholder\": \"Crie ou pesquise tags...\",\n          \"noResults\": \"Nenhuma tag correspondente encontrada\",\n          \"quickAdd\": \"Criação Rápida\",\n          \"pinned\": \"Fixadas\",\n          \"others\": \"Outras\",\n          \"rename\": \"Renomear\",\n          \"delete\": \"Excluir\",\n          \"pin\": \"Fixar\",\n          \"unpin\": \"Desafixar\"\n        },\n        \"progress\": {\n          \"cacheImage\": \"Armazenando imagem em cache\",\n          \"ocr\": \"Reconhecimento OCR\",\n          \"aiAnalysis\": \"Análise de conteúdo por IA\",\n          \"uploadImage\": \"Enviando para host de imagem\",\n          \"jsdelivrCache\": \"Notificando cache jsdelivr\",\n          \"cacheFile\": \"Armazenando arquivo em cache\",\n          \"cacheScreenshot\": \"Armazenando captura de tela em cache\",\n          \"textAnalysis\": \"Análise de texto\",\n          \"save\": \"Salvando\",\n          \"saveImage\": \"Salvando imagem\"\n        }\n      },\n      \"toolbar\": {\n        \"search\": \"Pesquisar\",\n        \"trash\": \"Lixeira\",\n        \"restore\": \"Restaurar\",\n        \"delete\": \"Excluir\",\n        \"deleteConfirm\": \"Tem certeza de que deseja excluir?\",\n        \"moveTag\": \"Mover para Tag\",\n        \"convertTo\": \"Converter para {type}\",\n        \"copyLink\": \"Copiar Link\",\n        \"copied\": \"Copiado para a área de transferência!\",\n        \"regenerateDesc\": \"Gerar Descrição Novamente\",\n        \"viewFolder\": \"Ver na Pasta\",\n        \"viewFile\": \"Ver Arquivo Original\",\n        \"deleteForever\": \"Excluir Permanentemente\",\n        \"sortByName\": \"Ordenar por Nome\",\n        \"sortByCreated\": \"Ordenar por Data de Criação\",\n        \"sortByModified\": \"Ordenar por Data de Modificação\",\n        \"sortAsc\": \"Ordem Crescente\",\n        \"sortDesc\": \"Ordem Decrescente\",\n        \"sort\": \"Ordenar\",\n        \"processingVectors\": \"Processando Dados Vetoriais\",\n        \"calculateVectors\": \"Calcular Vetores de Documento\",\n        \"multiSelect\": \"Seleção Múltipla\",\n        \"exitMultiSelect\": \"Sair da Seleção Múltipla\",\n        \"selectAll\": \"Selecionar Tudo\",\n        \"deselectAll\": \"Desmarcar Tudo\",\n        \"selectedCount\": \"{count} itens selecionados\",\n        \"moveSelectedTags\": \"Mover {count} itens selecionados\",\n        \"deleteSelected\": \"Excluir {count} itens selecionados\",\n        \"deleteSelectedForever\": \"Excluir {count} itens selecionados permanentemente\",\n        \"organizeNotes\": \"Organizar Notas\",\n        \"organizeSuccess\": \"Notas organizadas com sucesso: {title}\",\n        \"organizeError\": \"Falha ao organizar notas\",\n        \"currentTag\": \"Tag Atual\",\n        \"text\": \"Gravar Texto\",\n        \"recording\": \"Gravação de Áudio\",\n        \"scan\": \"Digitalizar Imagem\",\n        \"image\": \"Carregar Imagem\",\n        \"link\": \"Gravar Link\",\n        \"file\": \"Carregar Arquivo\",\n        \"todo\": \"Registro Todo\",\n        \"closeTrash\": \"Fechar lixeira\"\n      },\n      \"list\": {\n        \"title\": \"Registros\"\n      },\n      \"note\": {\n        \"organizeAs\": \"Organizar como\",\n        \"template\": \"Modelo\",\n        \"setting\": \"Configurações\",\n        \"confirm\": \"Confirmar\",\n        \"cancel\": \"Cancelar\",\n        \"removeThinking\": \"Remover processo de raciocínio\",\n        \"stop\": \"Parar\"\n      },\n      \"imageGallery\": {\n        \"expand\": \"Expandir\",\n        \"collapse\": \"Recolher\"\n      }\n    },\n    \"chat\": {\n      \"empty\": {\n        \"title\": \"Iniciar Conversa com IA\",\n        \"subtitle\": \"Use o modo Chat ou Agent para interagir com IA\",\n        \"currentModel\": \"Modelo Atual\",\n        \"currentPrompt\": \"Prompt Atual\",\n        \"noModel\": \"Nenhum modelo definido\",\n        \"noPrompt\": \"Nenhum prompt definido\",\n        \"modeHint\": \"Clique no botão\",\n        \"modeHintSuffix\": \"à esquerda da caixa de entrada para alternar o modo de conversa\",\n        \"currentMode\": \"Modo de conversa\",\n        \"configureModel\": \"Configurar modelo\",\n        \"features\": [\n          {\n            \"chat\": \"Converse com o assistente de IA\"\n          },\n          {\n            \"linked\": \"Vinculado aos seus registros ou notas\"\n          },\n          {\n            \"clipboard\": \"Reconhece texto e imagens da área de transferência\"\n          },\n          {\n            \"organize\": \"Organiza seus registros em notas\"\n          }\n        ],\n        \"recentConversations\": \"Conversas Recentes\",\n        \"deleteConversation\": \"Excluir Conversa\",\n        \"conversationHistory\": \"Histórico\",\n        \"viewMore\": \"Ver Mais\",\n        \"messages\": \"mensagens\",\n        \"searchPlaceholder\": \"Pesquisar conversas...\",\n        \"noMatchingConversations\": \"Nenhuma conversa correspondente encontrada\",\n        \"noConversationHistory\": \"Ainda não há histórico de conversas\",\n        \"quickPrompts\": {\n          \"title\": \"快速开始\",\n          \"writeNote\": \"帮我写一篇笔记\",\n          \"summarize\": \"帮我总结这段内容\",\n          \"brainstorm\": \"帮我头脑风暴一些想法\",\n          \"explain\": \"帮我解释这个概念\"\n        }\n      },\n      \"newChat\": \"Novo Chat com Nova Tag\",\n      \"removeChat\": \"Remover Chat com Tag Atual\",\n      \"confirmNew\": \"Criar Nova Tag\",\n      \"confirmNewDescription\": \"Tem certeza de que deseja criar uma nova tag para iniciar uma conversa?\",\n      \"confirmRemove\": \"Excluir Tag\",\n      \"confirmRemoveDescription\": \"Note que excluir esta tag também removerá todos os registros contidos nela. Confirme a ação para continuar.\",\n      \"content\": {\n        \"organize\": \"Organize seus registros em um artigo:\"\n      },\n      \"note\": {\n        \"organize\": \"Organizar\",\n        \"writing\": \"Escrever\",\n        \"convert\": \"Converter Artigo\",\n        \"description\": \"A nota atual é gerada por IA e não pode ser editada. Converta a nota atual em um artigo (gere um arquivo local) para edição posterior na página de escrita.\",\n        \"filename\": \"Nome do Arquivo\",\n        \"selectFolder\": \"Selecionar pasta\",\n        \"rootDirectory\": \"Diretório raiz\",\n        \"deleteTag\": \"Excluir tag atual, registros e notas (pode ser restaurado da lixeira)\",\n        \"warning\": \"Após a conversão, você será redirecionado para a página de escrita.\",\n        \"convert_button\": \"Converter\",\n        \"organizeAs\": \"Organize seus registros em um artigo:\",\n        \"templateContent\": \"Conteúdo do template\",\n        \"recordRange\": \"Intervalo de registros\",\n        \"filterThinkingContent\": \"Remover conteúdo de 'pensamento' dos registros\",\n        \"startOrganize\": \"Começar a organizar\",\n        \"manageTemplate\": \"Gerenciar template\",\n        \"cancel\": \"Cancelar\",\n        \"stop\": \"Parar\"\n      },\n      \"mark\": {\n        \"recorded\": \"Gravado\",\n        \"record\": \"Gravar\"\n      },\n      \"input\": {\n        \"organize\": \"Organizar\",\n        \"chat\": \"Bate-papo\",\n        \"placeholder\": {\n          \"default\": \"Digite uma mensagem...\",\n          \"noApiKey\": \"Nenhuma Chave de API configurada, não é possível usar o chat de IA...\",\n          \"on\": \"Sugestões de IA ativadas\",\n          \"off\": \"Sugestões de IA desativadas\",\n          \"noPrimaryModel\": \"Nenhum modelo principal configurado, não é possível usar o chat de IA...\"\n        },\n        \"translate\": {\n          \"tooltip\": \"Traduzir\",\n          \"translating\": \"Traduzindo...\",\n          \"showOriginal\": \"Mostrar Original\",\n          \"alreadyTranslated\": \"Traduzido para\"\n        },\n        \"clipboardMonitor\": {\n          \"enable\": \"Monitoramento da área de transferência (ligado)\",\n          \"disable\": \"Monitoramento da área de transferência (desligado)\"\n        },\n        \"send\": \"Enviar\",\n        \"stop\": \"Parar\",\n        \"terminate\": \"Terminar\",\n        \"tagLink\": {\n          \"on\": \"Vinculado à tag\",\n          \"off\": \"Não vinculado à tag\"\n        },\n        \"modelSelect\": {\n          \"tooltip\": \"Selecionar modelo de IA\",\n          \"placeholder\": \"Pesquisar modelos de IA\",\n          \"noModel\": \"Nenhum modelo encontrado\"\n        },\n        \"promptSelect\": {\n          \"tooltip\": \"Selecionar prompt\",\n          \"placeholder\": \"Pesquisar prompts\"\n        },\n        \"clearChat\": \"Limpar Chat\",\n        \"clearContext\": {\n          \"tooltip\": \"Limpar contexto\"\n        },\n        \"mcp\": {\n          \"tooltip\": \"Servidor MCP\"\n        },\n        \"chatLanguage\": {\n          \"tooltip\": \"Selecionar idioma do chat\",\n          \"placeholder\": \"Pesquisar idioma\"\n        },\n        \"rag\": {\n          \"notSupported\": \"Modelo vetorial não é suportado\",\n          \"enabled\": \"Busca na Base de Conhecimento (Habilitada)\",\n          \"disabled\": \"Busca na Base de Conhecimento (Desabilitada)\"\n        },\n        \"modeSelect\": {\n          \"tooltip\": \"Selecionar modo de entrada\",\n          \"chat\": \"Modo Chat\",\n          \"gen\": \"Modo Organizar\",\n          \"translate\": \"Modo Traduzir\"\n        },\n        \"chatModeSelect\": {\n          \"chatDescription\": \"Conversa rápida, foco em análise\",\n          \"agentDescription\": \"Assistente inteligente, pode executar ações\"\n        },\n        \"attachImage\": \"Anexar imagens\",\n        \"agent\": {\n          \"running\": \"Agente em Execução\",\n          \"thinking\": \"Pensando\",\n          \"analyzingRequest\": \"O agente está analisando sua solicitação...\",\n          \"acting\": \"Agindo\",\n          \"observation\": \"Observação\",\n          \"toolCalls\": \"Chamadas de Ferramenta\",\n          \"autoFinal\": {\n            \"createNote\": \"Nota \\\"{name}\\\" criada.\",\n            \"createFile\": \"Arquivo \\\"{name}\\\" criado.\"\n          },\n          \"thought\": \"Pensamento\",\n          \"action\": \"Ação\",\n          \"confirmation\": {\n            \"title\": \"Confirmar Ação\",\n            \"description\": \"O agente deseja executar a seguinte ação. Por favor, confirme para continuar.\",\n            \"tool\": \"Ferramenta\",\n            \"parameters\": \"Parâmetros\",\n            \"cancel\": \"Cancelar\",\n            \"confirm\": \"Confirmar\",\n            \"confirmed\": \"Confirmado\",\n            \"cancelled\": \"Cancelado\"\n          }\n        },\n        \"imageSelector\": {\n          \"title\": \"Selecionar Imagens\",\n          \"local\": \"Arquivos Locais\",\n          \"records\": \"Dos Registros\",\n          \"selectFiles\": \"Selecionar Imagens Locais\",\n          \"noRecords\": \"Nenhum registro de imagem disponível\",\n          \"cancel\": \"Cancelar\",\n          \"confirm\": \"Confirmar\"\n        },\n        \"fileLink\": {\n          \"tooltip\": \"Vincular Arquivo\",\n          \"selectFile\": \"Selecionar Arquivo\",\n          \"linkedFile\": \"Arquivo Vinculado\",\n          \"searchPlaceholder\": \"Pesquisar arquivos...\",\n          \"noFiles\": \"Nenhum arquivo encontrado\",\n          \"loading\": \"Carregando...\"\n        },\n        \"stopped\": \"A conversa foi encerrada\",\n        \"newChat\": \"开始新对话\"\n      },\n      \"header\": {\n        \"configApiKey\": \"Configurar CHAVE DE API\",\n        \"clearChat\": \"Limpar Chat\",\n        \"selectPrompt\": \"Selecionar Prompt\",\n        \"noModel\": \"Modelo de IA não selecionado\",\n        \"configPrompt\": \"Configurar Prompt\"\n      },\n      \"clipboard\": {\n        \"image\": {\n          \"detected\": \"Imagem detectada na área de transferência:\",\n          \"recording\": \"Gravando...\",\n          \"recorded\": \"Gravado\",\n          \"record\": \"Gravar\"\n        },\n        \"text\": {\n          \"detected\": \"Texto detectado na área de transferência:\",\n          \"recorded\": \"Gravado\",\n          \"record\": \"Gravar\"\n        }\n      },\n      \"messageControl\": {\n        \"words\": \"palavras\",\n        \"summary\": \"Resumo\",\n        \"readAloud\": \"Ler em Voz Alta\",\n        \"playing\": \"Reproduzindo\",\n        \"loading\": \"Carregando\",\n        \"stop\": \"Parar Reprodução\",\n        \"copy\": \"Copiar\",\n        \"copied\": \"Copiado\"\n      },\n      \"ragSources\": {\n        \"label\": \"Encontrado {count} notas na base de conhecimento\",\n        \"openFile\": \"Abrir arquivo\"\n      },\n      \"preview\": {\n        \"close\": \"Fechar\",\n        \"copy\": \"Copiar\",\n        \"copied\": \"Copiado!\"\n      },\n      \"control\": {\n        \"edit\": \"Editar\",\n        \"save\": \"Salvar\",\n        \"cancel\": \"Cancelar\",\n        \"delete\": \"Excluir\",\n        \"deleteConfirm\": \"Tem certeza de que deseja excluir esta mensagem?\"\n      },\n      \"condensing\": \"正在压缩上下文...\",\n      \"condensed\": {\n        \"message\": \"已压缩 {count} 条历史消息\"\n      },\n      \"quote\": {\n        \"lineSingle\": \"引用自 {fileName} 第 {line} 行\",\n        \"lineRange\": \"引用自 {fileName} 第 {startLine}-{endLine} 行\",\n        \"noLine\": \"引用自 {fileName}\"\n      }\n    },\n    \"tag\": {\n      \"add\": \"Adicionar Tag\",\n      \"edit\": \"Editar Tag\",\n      \"delete\": \"Excluir Tag\",\n      \"deleteConfirm\": \"Tem certeza de que deseja excluir esta tag?\",\n      \"placeholder\": \"Insira o nome da tag\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"Pesquisar notas e artigos...\",\n    \"results\": \"{count} resultados da pesquisa\",\n    \"noResults\": \"Nenhum resultado de pesquisa\",\n    \"tryDifferentKeywords\": \"Tente usar palavras-chave diferentes\",\n    \"item\": {\n      \"record\": \"Registro\",\n      \"article\": \"Artigo\",\n      \"matches\": \"{count} correspondências\",\n      \"scanType\": \"digitalização\"\n    },\n    \"mode\": {\n      \"fuzzy\": \"Aproximada\",\n      \"exact\": \"Exata\"\n    }\n  },\n  \"image\": {\n    \"root\": \"Repositório de Imagens\",\n    \"noData\": {\n      \"title\": \"Recurso de sincronização não habilitado\",\n      \"desc\": \"Por favor, vá para a página de configurações do sistema para configurar a sincronização do Github.\",\n      \"goToSettings\": \"Ir para Configurações\",\n      \"howToUse\": \"Como usar o recurso de sincronização?\"\n    }\n  },\n  \"navigation\": {\n    \"chat\": \"Conversa\",\n    \"record\": \"Registro\",\n    \"quickRecord\": \"Registro Rápido\",\n    \"write\": \"Escrever\",\n    \"search\": \"Pesquisar\",\n    \"githubImageHosting\": \"Hospedagem de Imagens Github\",\n    \"login\": \"Entrar\",\n    \"loading\": \"Carregando\",\n    \"view\": \"Visualizar\",\n    \"logout\": \"Sair\",\n    \"setting\": \"Configurações\",\n    \"activity\": \"Atividade\",\n    \"files\": \"Notas\",\n    \"outline\": \"Sumário\",\n    \"hideLeftSidebar\": \"Ocultar Barra Lateral Esquerda\",\n    \"showLeftSidebar\": \"Mostrar Barra Lateral Esquerda\",\n    \"hideRightSidebar\": \"Ocultar Barra Lateral Direita\",\n    \"showRightSidebar\": \"Mostrar Barra Lateral Direita\",\n    \"searchPlaceholder\": \"Pesquisar notas ou registros...\",\n    \"showCenterPanel\": \"Mostrar editor\",\n    \"hideCenterPanel\": \"Ocultar editor\"\n  },\n  \"activity\": {\n    \"title\": \"Calendário de Atividade\",\n    \"description\": \"Veja seus registros, conversas e atividade de escrita por dia. Esta primeira versão é calculada a partir dos registros existentes, conversas do usuário e horários de modificação das notas.\",\n    \"drawer\": {\n      \"title\": \"Atividade\",\n      \"description\": \"Veja rapidamente o status de hoje e a tendência recente da sua atividade.\",\n      \"today\": \"Hoje\"\n    },\n    \"loading\": \"Carregando dados de atividade...\",\n    \"empty\": \"Ainda não há dados de atividade\",\n    \"refresh\": \"Atualizar\",\n    \"summary\": {\n      \"totalCount\": \"Atividade Total\",\n      \"activeDays\": \"Dias Ativos\",\n      \"records\": \"Registros\",\n      \"chats\": \"Conversas\",\n      \"writing\": \"Escrita\"\n    },\n    \"labels\": {\n      \"record\": \"Registro\",\n      \"writing\": \"Escrita\",\n      \"chat\": \"Conversa\"\n    },\n    \"heatmap\": {\n      \"title\": \"Últimas 26 Semanas\",\n      \"range\": \"{startDate} - {endDate}\",\n      \"less\": \"Menos\",\n      \"more\": \"Mais\",\n      \"dayCount\": \"atividades\",\n      \"emptyDay\": \"Sem atividade\"\n    },\n    \"detail\": {\n      \"title\": \"Detalhes do Dia\",\n      \"empty\": \"Selecione um dia para ver seus detalhes de atividade.\"\n    }\n  },\n  \"marks\": {\n    \"types\": {\n      \"screenshot\": \"Captura de Tela\",\n      \"text\": \"Texto\",\n      \"image\": \"Imagem\"\n    }\n  },\n  \"tags\": {\n    \"inspiration\": \"Inspiração\"\n  },\n  \"sync\": {\n    \"status\": \"Status do Repositório de Sincronização\",\n    \"imageRepo\": \"Repositório de Imagens\",\n    \"articleRepo\": \"Repositório de Artigos\"\n  },\n  \"ai\": {\n    \"thinking\": \"Pensando\",\n    \"error\": {\n      \"title\": \"Erro de IA\",\n      \"noAddress\": \"Por favor, defina o endereço da IA primeiro\"\n    }\n  },\n  \"article\": {\n    \"sync\": {\n      \"syncingRemote\": \"Buscando arquivo remoto...\",\n      \"syncComplete\": \"Sincronização Concluída\",\n      \"pullingRemote\": \"Obtendo conteúdo mais recente do servidor remoto...\"\n    },\n    \"syncConfirm\": {\n      \"title\": \"Atualização de Arquivo Remoto Detectada\",\n      \"description\": \"Arquivo {fileName} tem atualizações remotas\",\n      \"commitInfo\": \"Informações do Último Commit\",\n      \"commitMessage\": \"Mensagem do Commit\",\n      \"author\": \"Autor\",\n      \"changes\": \"Alterações\",\n      \"confirmMessage\": \"Tem certeza de que deseja buscar a versão remota e substituir o arquivo local? Esta ação não pode ser desfeita.\",\n      \"cancel\": \"Cancelar\",\n      \"confirmPull\": \"Confirmar Busca\"\n    },\n    \"emptyState\": {\n      \"title\": \"Começar a Criar\",\n      \"subtitle\": \"Selecione um arquivo para começar a editar ou crie uma nova nota\",\n      \"tip\": \"💡 Dica: Você também pode selecionar arquivos no gerenciador de arquivos à esquerda\",\n      \"actions\": {\n        \"newNote\": {\n          \"title\": \"Criar Nota\",\n          \"desc\": \"Criar uma nova nota Markdown\"\n        },\n        \"newRecord\": {\n          \"title\": \"Criar Registro\",\n          \"desc\": \"Abrir função de registro de texto\"\n        },\n        \"globalSearch\": {\n          \"title\": \"Pesquisa Global\",\n          \"desc\": \"Encontre rapidamente o conteúdo de suas notas\"\n        },\n        \"openWorkspace\": {\n          \"title\": \"Abrir Workspace\",\n          \"desc\": \"Selecionar ou alternar diretório de workspace\"\n        }\n      },\n      \"onboarding\": {\n        \"title\": \"Onboarding\",\n        \"subtitle\": \"Siga estas três tarefas para conhecer o fluxo principal do NoteGen.\",\n        \"dismiss\": \"Pular introducao\",\n        \"reopen\": \"Mostrar onboarding novamente\",\n        \"start\": \"Começar\",\n        \"viewHint\": \"Ver dica\",\n        \"continue\": \"Continuar\",\n        \"completed\": \"Concluído\",\n        \"allDone\": \"Todas as tarefas iniciais foram concluídas. Você já experimentou o fluxo principal do NoteGen.\",\n        \"stepLabel\": \"Tarefa ({current}/{total})\",\n        \"stepCompletedLabel\": \"Tarefa concluída ({current}/{total})\",\n        \"afterOrganizeDialog\": {\n          \"title\": \"Tarefa concluída (2/3)\",\n          \"description\": \"Seu registro virou uma nota. Quer continuar e usar o AI Agent para transformar essa nota em uma versão bilíngue?\",\n          \"confirm\": \"Continuar\",\n          \"cancel\": \"Agora não\"\n        },\n        \"agentPrompt\": {\n          \"label\": \"Prompt de exemplo\",\n          \"use\": \"Usar este prompt\",\n          \"intro\": \"Please directly revise the note I just organized into a bilingual Chinese-English version.\",\n          \"requirement1\": \"\",\n          \"requirement2\": \"\",\n          \"requirement3\": \"\",\n          \"requirement4\": \"\",\n          \"outro\": \"\"\n        },\n        \"steps\": {\n          \"createRecord\": {\n            \"title\": \"Criar o primeiro registro\",\n            \"desc\": \"Salve um registro de exemplo e veja onde fica a captura rápida.\"\n          },\n          \"organizeNote\": {\n            \"title\": \"Organizar em nota\",\n            \"desc\": \"Transforme esse registro em uma nota estruturada.\"\n          },\n          \"aiPolish\": {\n            \"title\": \"Usar Agent para tradução bilíngue\",\n            \"desc\": \"Use o AI Agent para transformar a nota recém-organizada em uma versão bilíngue.\"\n          }\n        },\n        \"completedStates\": {\n          \"create-record\": {\n            \"title\": \"Seu primeiro registro foi criado\",\n            \"desc\": \"Agora você já sabe onde fica a captura rápida.\"\n          },\n          \"organize-note\": {\n            \"title\": \"Seu registro virou uma nota\",\n            \"desc\": \"Agora vale experimentar a IA editando essa nota.\"\n          },\n          \"ai-polish\": {\n            \"title\": \"Você usou o Agent na nota\",\n            \"desc\": \"Você concluiu o fluxo de captura, organização e processamento com Agent no NoteGen.\"\n          }\n        },\n        \"spotlight\": {\n          \"create-record\": {\n            \"title\": \"Aqui fica a captura rápida\",\n            \"desc\": \"Clique aqui para abrir o registro em texto. Vamos preencher um exemplo automaticamente para você salvar logo.\"\n          },\n          \"organize-note\": {\n            \"title\": \"Este botão organiza registros em uma nota\",\n            \"desc\": \"Use-o para transformar o registro salvo em uma nota Markdown completa.\"\n          },\n          \"ai-polish\": {\n            \"title\": \"Use o Agent aqui na nota que acabou de criar\",\n            \"desc\": \"Insira o prompt de exemplo no chat e envie. O Agent vai gerar uma versão bilíngue com base na nota atual.\"\n          }\n        }\n      }\n    },\n    \"unsupportedFile\": {\n      \"title\": \"Não É Possível Visualizar Este Arquivo\",\n      \"fileName\": \"Nome do Arquivo\",\n      \"filePath\": \"Caminho do Arquivo\",\n      \"fileSize\": \"Tamanho do Arquivo\",\n      \"modifiedTime\": \"Data de Modificação\",\n      \"createdTime\": \"Data de Criação\",\n      \"pathCopied\": \"Caminho copiado\",\n      \"openExternal\": \"Abrir com App Externo\",\n      \"openDirectory\": \"Abrir Diretório do Arquivo\"\n    },\n    \"file\": {\n      \"toolbar\": {\n        \"accessRepo\": \"Acessar Repositório\",\n        \"loadingSync\": \"Carregando informações de sincronização\",\n        \"configSync\": \"Configurar Sincronização\",\n        \"newArticle\": \"Novo Artigo\",\n        \"newFolder\": \"Nova Pasta\",\n        \"refresh\": \"Atualizar\",\n        \"toggleFolders\": \"Alternar Pastas\",\n        \"expandAll\": \"Expandir Tudo\",\n        \"collapseAll\": \"Recolher Tudo\",\n        \"sortByName\": \"Ordenar por Nome\",\n        \"sortByCreated\": \"Ordenar por Data de Criação\",\n        \"sortByModified\": \"Ordenar por Data de Modificação\",\n        \"sortAsc\": \"Ordem Crescente\",\n        \"sortDesc\": \"Ordem Decrescente\",\n        \"sort\": \"Ordenar\",\n        \"hideCloudFiles\": \"Ocultar Arquivos da Nuvem\",\n        \"showCloudFiles\": \"Mostrar Arquivos da Nuvem\",\n        \"processingVectors\": \"Processando Dados Vetoriais\",\n        \"calculateVectors\": \"Cálculo da Base de Conhecimento (Completo)\",\n        \"importMarkdown\": \"Importar\",\n        \"importing\": \"Importando...\",\n        \"importSuccess\": \"Importação Bem-sucedida\",\n        \"importSuccessDesc\": \"Importados {count} arquivos com sucesso\",\n        \"importError\": \"Falha na Importação\"\n      },\n      \"sync\": {\n        \"syncingRemote\": \"Buscando arquivo remoto...\",\n        \"syncComplete\": \"Sincronização Concluída\",\n        \"pullingRemote\": \"Buscando conteúdo mais recente do servidor remoto...\",\n        \"pullComplete\": \"Pull Concluído\"\n      },\n      \"context\": {\n        \"viewDirectory\": \"Ver Diretório\",\n        \"cut\": \"Recortar\",\n        \"copy\": \"Copiar\",\n        \"paste\": \"Colar\",\n        \"rename\": \"Renomear\",\n        \"deleteSyncFile\": \"Excluir Arquivo Sincronizado\",\n        \"deleteLocalFile\": \"Excluir Arquivo Local\",\n        \"delete\": \"Excluir\",\n        \"confirmDelete\": \"Tem certeza de que deseja excluir a pasta \\\"{name}\\\"? Isso excluirá a pasta e todo o seu conteúdo.\",\n        \"deleteSuccess\": \"Excluído com sucesso\",\n        \"deleteFailed\": \"Falha ao excluir\",\n        \"newFile\": \"Novo Arquivo\",\n        \"newFolder\": \"Nova Pasta\",\n        \"syncFolder\": \"Sincronizar Pasta\",\n        \"syncFolderDesc\": \"Sincronizar todos os arquivos Markdown na pasta atual\",\n        \"syncFolderSuccess\": \"Sucesso ao sincronizar pasta\",\n        \"syncFolderError\": \"Erro ao sincronizar pasta\",\n        \"syncFolderProgress\": \"Sincronizando pasta...\",\n        \"deleteSyncFileSuccess\": \"Sucesso ao Excluir Arquivo Sincronizado\",\n        \"deleteSyncFileError\": \"Erro ao Excluir Arquivo Sincronizado\",\n        \"knowledgeBase\": \"Base de Conhecimento\",\n        \"calculateVectors\": \"Calcular Vetores\",\n        \"updateVectors\": \"Atualizar Vetores\",\n        \"deleteVectors\": \"Excluir Vetores\",\n        \"includeInKB\": \"Incluir na Base de Conhecimento\",\n        \"includeInKBFile\": \"Incluir na Base de Conhecimento\",\n        \"autoVectorCalc\": \"Cálculo Automático de Vetores\",\n        \"vectorCalculated\": \"Vetor Atualizado\",\n        \"vectorCalcCompleted\": \"Cálculo de Vetor Concluído\",\n        \"vectorCalcFailed\": \"Falha no Cálculo de Vetor\",\n        \"vectorDeleted\": \"Vetor Excluído\",\n        \"vectorDeleteFailed\": \"Falha ao Excluir Vetor\",\n        \"batchCalcSuccess\": \"Vetores calculados com sucesso para {count} arquivos\",\n        \"batchCalcPartial\": \"Cálculo concluído: {success} succeeded, {failed} failed\",\n        \"batchCalcFailed\": \"Falha no cálculo em lote de vetores\",\n        \"batchDeleteSuccess\": \"Vetores excluídos com sucesso para {count} arquivos\",\n        \"batchDeletePartial\": \"Exclusão concluída: {success} succeeded, {failed} failed\",\n        \"batchDeleteFailed\": \"Falha na exclusão em lote de vetores\",\n        \"noMarkdownFiles\": \"Nenhum arquivo Markdown na pasta\",\n        \"includedInKB\": \"Incluído na Base de Conhecimento\",\n        \"excludedFromKB\": \"Excluído da Base de Conhecimento\",\n        \"autoCalcEnabled\": \"Cálculo automático de vetores habilitado\",\n        \"autoCalcDisabled\": \"Cálculo automático de vetores desabilitado\",\n        \"settingFailed\": \"Falha na configuração\",\n        \"confirmDeleteVectors\": \"Tem certeza de que deseja excluir vetores de {count} arquivos?\"\n      },\n      \"folderView\": {\n        \"vectorDbNotEnabled\": \"Banco de dados vetorial não está habilitado\",\n        \"calculateVectors\": \"Calcular Vetores\",\n        \"indexed\": \"Indexado\",\n        \"vectorCount\": \"Contagem de Vetores\",\n        \"databaseSize\": \"Tamanho do Banco de Dados\",\n        \"lastCalculated\": \"Último Cálculo\",\n        \"never\": \"Nunca\",\n        \"calculating\": \"Calculando...\",\n        \"failed\": \"Falhou\",\n        \"recalculateVectors\": \"Recalcular Vetores\",\n        \"skills\": \"Skills\",\n        \"skillNotFound\": \"Skill Não Encontrado\",\n        \"skillNotFoundDesc\": \"Não foi possível encontrar Skill com ID {id}\",\n        \"loadingSkills\": \"Carregando Skills...\",\n        \"loadingSkill\": \"Carregando Skill...\",\n        \"globalSkills\": \"Skills Globais\",\n        \"workspaceSkills\": \"Skills do Workspace\",\n        \"instructions\": \"Instruções\",\n        \"examples\": \"Exemplos\",\n        \"scripts\": \"Scripts\",\n        \"references\": \"Referências\",\n        \"assets\": \"Ativos\"\n      },\n      \"error\": {\n        \"fileExists\": \"O nome do arquivo já existe\"\n      },\n      \"clipboard\": {\n        \"copied\": \"Copiado para a área de transferência\",\n        \"cut\": \"Recortado para a área de transferência\",\n        \"pasted\": \"Colado com sucesso\",\n        \"pasteFailed\": \"Falha na operação de colar\",\n        \"empty\": \"A área de transferência está vazia\",\n        \"confirmOverwrite\": \"O arquivo já existe, deseja sobrescrevê-lo?\",\n        \"mark\": {\n          \"title\": \"Registros\",\n          \"tooltip\": \"Usar Registros\",\n          \"description\": \"Converta registros em conteúdo para inserir no artigo.\",\n          \"noRecords\": \"Nenhum registro\",\n          \"ocrNoContent\": \"OCR não reconheceu nenhum conteúdo\"\n        },\n        \"question\": {\n          \"tooltip\": \"Perguntas e Respostas\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Texto de referência: \\n{content}\\nCom base na pergunta: \\n{question}\\n, forneça diretamente o conteúdo da resposta.\"\n        },\n        \"continue\": {\n          \"tooltip\": \"Continuar\",\n          \"promptTemplate\": \"Com base no texto anterior: \\n{content}\\n continue escrevendo e retorne um conteúdo que não exceda 100 palavras.\\nVocê pode referenciar o seguinte texto: \\n{endContent}\\n, mas evite duplicar seu conteúdo.\"\n        },\n        \"polish\": {\n          \"tooltip\": \"Polir\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Polir este texto: \\n{content}\\n, mantenha o idioma inalterado, corrija erros de digitação e gramaticais, retorne diretamente o resultado polido.\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"Simplificar\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Simplificar este texto: \\n{content}\\n, este texto está muito verboso, reduza a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado otimizado.\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"Expandir\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Expandir este texto: \\n{content}\\n, este texto está muito curto, aumente a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado expandido.\"\n        },\n        \"translation\": {\n          \"tooltip\": \"Traduzir\",\n          \"description\": \"Traduzir o texto selecionado\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Traduzir este texto: \\n{content}\\n, para {language}, retorne diretamente o resultado traduzido.\"\n        },\n        \"notSupported\": \"Esta ação não é suportada\"\n      },\n      \"deleteConfirm\": \"Confirmar exclusão deste arquivo?\"\n    },\n    \"editor\": {\n      \"copySuccess\": \"Cópia Bem-sucedida\",\n      \"copySuccessDescription\": \"Copiado para a área de transferência\",\n      \"search\": {\n        \"placeholder\": \"Localizar no documento\",\n        \"replacePlaceholder\": \"Substituir por\",\n        \"caseSensitive\": \"Diferenciar maiúsculas e minúsculas\",\n        \"replace\": \"Substituir\",\n        \"replaceAll\": \"Substituir tudo\",\n        \"findPrev\": \"Anterior\",\n        \"findNext\": \"Próximo\"\n      },\n      \"floatbar\": {\n        \"quote\": {\n          \"tooltip\": \"Citar\"\n        },\n        \"readAloud\": {\n          \"start\": \"Ler em Voz Alta\",\n          \"stop\": \"Parar Leitura\",\n          \"loading\": \"Carregando...\"\n        }\n      },\n      \"toolbar\": {\n        \"organize\": {\n          \"tooltip\": \"Organizar Notas\"\n        },\n        \"mark\": {\n          \"title\": \"Registros\",\n          \"tooltip\": \"Registros\",\n          \"description\": \"Converta registros em conteúdo para inserir no artigo.\",\n          \"noRecords\": \"Nenhum registro\",\n          \"ocrNoContent\": \"OCR não reconheceu nenhum conteúdo\"\n        },\n        \"question\": {\n          \"tooltip\": \"Perguntas e Respostas\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Texto de referência: \\n{content}\\nCom base na pergunta: \\n{question}\\n, forneça diretamente o conteúdo da resposta.\"\n        },\n        \"continue\": {\n          \"tooltip\": \"Continuar\",\n          \"promptTemplate\": \"Com base no texto anterior: \\n{content}\\n continue escrevendo e retorne um conteúdo que não exceda 100 palavras.\\nVocê pode referenciar o seguinte texto: \\n{endContent}\\n, mas evite duplicar seu conteúdo.\"\n        },\n        \"polish\": {\n          \"tooltip\": \"Polir\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Polir este texto: \\n{content}\\n, mantenha o idioma inalterado, corrija erros de digitação e gramaticais, retorne diretamente o resultado polido.\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"Simplificar\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Simplificar este texto: \\n{content}\\n, este texto está muito verboso, reduza a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado otimizado.\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"Expandir\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Expandir este texto: \\n{content}\\n, este texto está muito curto, aumente a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado expandido.\"\n        },\n        \"translation\": {\n          \"tooltip\": \"Traduzir\",\n          \"description\": \"Traduzir o texto selecionado\",\n          \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n          \"promptTemplate\": \"Traduzir este texto: \\n{content}\\n, para {language}, retorne diretamente o resultado traduzido.\",\n          \"fail\": \"Falha na tradução\",\n          \"failNoSelection\": \"Por favor, selecione o texto para traduzir\",\n          \"translating\": \"Traduzindo\",\n          \"translatingTo\": \"Traduzindo para {language}...\",\n          \"success\": \"Tradução concluída\",\n          \"successTo\": \"Traduzido para {language}\",\n          \"customLanguage\": \"Idioma personalizado...\",\n          \"customLanguagePlaceholder\": \"Digite o idioma de destino, ex.: Inglês, Japonês, etc.\",\n          \"customLanguageEmpty\": \"Por favor, digite o idioma de destino\",\n          \"customLanguageExample\": \"ex.: Inglês, Japonês, Francês, etc.\"\n        }\n      },\n      \"upload\": {\n        \"error\": \"Falha no upload\",\n        \"needToken\": \"O upload de imagens precisa configurar o accessToken\",\n        \"uploading\": \"Enviando imagem\"\n      },\n      \"saveDialog\": {\n        \"title\": \"Salvar arquivo\",\n        \"emptyContent\": \"Conteúdo vazio\",\n        \"emptyContentDesc\": \"Digite algum conteúdo antes de salvar.\",\n        \"success\": \"Salvo com sucesso\",\n        \"successDesc\": \"O arquivo foi salvo.\",\n        \"error\": \"Falha ao salvar\",\n        \"errorDesc\": \"Não foi possível salvar o arquivo. Tente novamente.\"\n      }\n    },\n    \"footer\": {\n      \"wordCount\": \"Contagem de Palavras\",\n      \"pull\": {\n        \"pull\": \"Pull\",\n        \"checking\": \"Verificando atualizações...\",\n        \"noUpdate\": \"Sem atualizações remotas\",\n        \"clickToPull\": \"Clique para puxar atualizações remotas\",\n        \"pullSuccess\": \"Pull Bem-sucedido\",\n        \"pullFailed\": \"Pull Falhou\",\n        \"ignored\": \"Ignorado\",\n        \"ignoreUpdate\": \"Ignorar Esta Atualização\"\n      },\n      \"sync\": {\n        \"sync\": \"Sincronizar\",\n        \"synced\": \"Sincronizado\",\n        \"syncing\": \"Sincronizando\",\n        \"syncFailed\": \"Falha na Sincronização\",\n        \"checkNetworkOrToken\": \"Por favor, verifique a conexão de rede ou o token\",\n        \"quickSync\": \"Sincronização Rápida\",\n        \"push\": \"Enviar\",\n        \"pushed\": \"Enviado\"\n      },\n      \"history\": {\n        \"loadingHistory\": \"Carregando histórico\",\n        \"historyRecords\": \"Registros do Histórico\",\n        \"noHistory\": \"Nenhum Histórico\",\n        \"loading\": \"Carregando\",\n        \"recordsCount\": \"registros\",\n        \"filterQuickSync\": \"Filtrar Sincronizações Rápidas\",\n        \"committedAt\": \"enviado em\",\n        \"pull\": \"Pull\",\n        \"quickSync\": \"Sincronização Rápida\"\n      },\n      \"vectorCalc\": {\n        \"tooltip\": {\n          \"default\": \"Status do Índice Vetorial\",\n          \"none\": \"Clique para iniciar o cálculo vetorial\",\n          \"indexed\": \"Indexado\",\n          \"pending\": \"Atualização pendente, clique para calcular agora\",\n          \"calculating\": \"Calculando...\"\n        },\n        \"status\": {\n          \"calculating\": \"Calculando\"\n        }\n      }\n    }\n  },\n  \"mobile\": {\n    \"chat\": {\n      \"drawer\": {\n        \"settings\": {\n          \"title\": \"Configurações de Chat\"\n        },\n        \"tools\": {\n          \"title\": \"Ferramentas\",\n          \"clearContext\": \"Limpar Contexto\",\n          \"clearContextDesc\": \"Limpar contexto da conversa, manter histórico\",\n          \"clearChat\": \"Limpar Chat\",\n          \"clearChatDesc\": \"Excluir todos os registros de chat\",\n          \"clear\": \"Limpar\",\n          \"newChat\": \"开始新对话\",\n          \"start\": \"开始\"\n        },\n        \"attachments\": {\n          \"title\": \"Anexos\",\n          \"gallery\": \"Galeria\",\n          \"camera\": \"Câmera\",\n          \"file\": \"Arquivo\",\n          \"linkNote\": \"Vincular Nota\"\n        }\n      }\n    }\n  },\n  \"mcp\": {\n    \"selectServers\": \"Servidores MCP\",\n    \"searchServers\": \"Buscar servidores...\",\n    \"noServers\": \"Serviço MCP não habilitado\",\n    \"noServersFound\": \"Nenhum servidor correspondente encontrado\",\n    \"addServer\": \"Adicionar servidor...\",\n    \"goToSettings\": \"Ir para Configurações\",\n    \"close\": \"Fechar\",\n    \"navigate\": \"Selecionar\",\n    \"confirm\": \"Confirmar\",\n    \"tools\": \"ferramentas\",\n    \"connecting\": \"Conectando\",\n    \"disconnected\": \"Desconectado\"\n  },\n  \"recording\": {\n    \"title\": \"Gravação de Voz\",\n    \"description\": \"Clique no botão do microfone para iniciar a gravação, o sistema reconhecerá automaticamente e converterá para texto\",\n    \"recording\": \"Gravando\",\n    \"paused\": \"Pausado\",\n    \"ready\": \"Pronto\",\n    \"processing\": \"Processando...\",\n    \"cancel\": \"Cancelar\",\n    \"error\": \"Erro\",\n    \"success\": \"Sucesso\",\n    \"noModelConfigured\": \"Modelo de reconhecimento de fala não configurado, configure nas configurações primeiro\",\n    \"speechUnavailable\": \"O modo atual de reconhecimento de fala não está disponível. Verifique o suporte local ou a configuração do modelo.\",\n    \"fallbackToModel\": \"O reconhecimento local não está disponível, então o app mudou automaticamente para a transcrição por modelo.\",\n    \"startError\": \"Não é possível iniciar a gravação\",\n    \"noAudioData\": \"Nenhum dado de áudio gravado\",\n    \"transcriptionSuccess\": \"Reconhecimento de fala concluído\",\n    \"transcriptionEmpty\": \"Resultado do reconhecimento está vazio\",\n    \"transcriptionError\": \"Falha no reconhecimento de fala\",\n    \"configureModel\": \"Configurar modelo\",\n    \"retryTranscription\": \"Transcrever novamente\",\n    \"retrying\": \"Transcrevendo novamente...\",\n    \"retrySuccess\": \"Transcrição atualizada\",\n    \"retryError\": \"Falha ao transcrever novamente\",\n    \"noContentDetected\": \"Nenhum conteúdo detectado\",\n    \"doubleClickToSelectFile\": \"Clique duas vezes para selecionar o arquivo de áudio\",\n    \"mode\": {\n      \"builtin\": \"Reconhecimento do Navegador\",\n      \"builtinDesc\": \"Gratuito, reconhecimento em tempo real\",\n      \"model\": \"Reconhecimento por Modelo de IA\",\n      \"modelDesc\": \"Requer modelo STT, mais preciso\"\n    }\n  },\n  \"editor\": {\n    \"placeholder\": \"Digite / para abrir o menu, ou comece a escrever...\",\n    \"outline\": {\n      \"title\": \"Esboço\",\n      \"open\": \"Abrir Esboço\",\n      \"close\": \"Fechar Esboço\"\n    },\n    \"copySuccess\": \"Cópia Bem-sucedida\",\n    \"copySuccessDescription\": \"Copiado para a área de transferência\",\n    \"search\": {\n      \"placeholder\": \"Localizar no documento\",\n      \"replacePlaceholder\": \"Substituir por\",\n      \"caseSensitive\": \"Diferenciar maiúsculas e minúsculas\",\n      \"replace\": \"Substituir\",\n      \"replaceAll\": \"Substituir tudo\",\n      \"findPrev\": \"Anterior\",\n      \"findNext\": \"Próximo\"\n    },\n    \"floatbar\": {\n      \"readAloud\": {\n        \"start\": \"Ler em Voz Alta\",\n        \"stop\": \"Parar Leitura\",\n        \"loading\": \"Carregando...\"\n      }\n    },\n    \"toolbar\": {\n      \"mark\": {\n        \"title\": \"Registros\",\n        \"tooltip\": \"Registros\",\n        \"description\": \"Converta registros em conteúdo para inserir no artigo.\",\n        \"noRecords\": \"Nenhum registro\",\n        \"ocrNoContent\": \"OCR não reconheceu nenhum conteúdo\"\n      },\n      \"question\": {\n        \"tooltip\": \"Perguntas e Respostas\",\n        \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n        \"promptTemplate\": \"Texto de referência: \\n{content}\\nCom base na pergunta: \\n{question}\\n, forneça diretamente o conteúdo da resposta.\"\n      },\n      \"continue\": {\n        \"tooltip\": \"Continuar\",\n        \"promptTemplate\": \"Com base no texto anterior: \\n{content}\\n continue escrevendo e retorne um conteúdo que não exceda 100 palavras.\\nVocê pode referenciar o seguinte texto: \\n{endContent}\\n, mas evite duplicar seu conteúdo.\"\n      },\n      \"polish\": {\n        \"tooltip\": \"Polir\",\n        \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n        \"promptTemplate\": \"Polir este texto: \\n{content}\\n, mantenha o idioma inalterado, corrija erros de digitação e gramaticais, retorne diretamente o resultado polido.\"\n      },\n      \"eraser\": {\n        \"tooltip\": \"Simplificar\",\n        \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n        \"promptTemplate\": \"Simplificar este texto: \\n{content}\\n, este texto está muito verboso, reduza a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado otimizado.\"\n      },\n      \"expansion\": {\n        \"tooltip\": \"Expandir\",\n        \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n        \"promptTemplate\": \"Expandir este texto: \\n{content}\\n, este texto está muito curto, aumente a contagem de palavras pela metade, mantenha o idioma inalterado, retorne diretamente o resultado expandido.\"\n      },\n      \"translation\": {\n        \"tooltip\": \"Traduzir\",\n        \"description\": \"Traduzir o texto selecionado\",\n        \"selectContent\": \"Por favor, selecione o conteúdo primeiro\",\n        \"promptTemplate\": \"Traduzir este texto: \\n{content}\\n, para {language}, retorne diretamente o resultado traduzido.\",\n        \"fail\": \"Falha na tradução\",\n        \"failNoSelection\": \"Por favor, selecione o texto para traduzir\",\n        \"translating\": \"Traduzindo...\",\n        \"translatingTo\": \"Traduzindo para {language}...\",\n        \"success\": \"Tradução concluída\",\n        \"successTo\": \"Traduzido para {language}\",\n        \"customLanguageEmpty\": \"Por favor, digite o idioma de destino\",\n        \"customLanguageExample\": \"ex.: Inglês, Japonês, Francês, etc.\"\n      },\n      \"quoteDisplay\": {\n        \"fromFile\": \"Citado de {fileName}\",\n        \"line\": \"Citado de {fileName} linha {line}\",\n        \"lines\": \"Citado de {fileName} linhas {start}-{end}\"\n      }\n    },\n    \"upload\": {\n      \"error\": \"Falha no upload\",\n      \"needToken\": \"O upload de imagens precisa configurar o accessToken\",\n      \"uploading\": \"Enviando imagem\"\n    }\n  },\n  \"footer\": {\n    \"wordCount\": \"Palavras\",\n    \"sync\": {\n      \"push\": \"Push\",\n      \"pushed\": \"Enviado\",\n      \"syncing\": \"Enviando\",\n      \"syncFailed\": \"Falha no Push\",\n      \"checkNetworkOrToken\": \"Por favor, verifique a conexão de rede ou o token\",\n      \"quickSync\": \"Sincronização Rápida\"\n    },\n    \"history\": {\n      \"loadingHistory\": \"Carregando histórico\",\n      \"historyRecords\": \"Registros do Histórico\",\n      \"noHistory\": \"Nenhum Histórico\",\n      \"loading\": \"Carregando\",\n      \"recordsCount\": \"registros\",\n      \"filterQuickSync\": \"Filtrar Sincronizações Rápidas\",\n      \"committedAt\": \"enviado em\",\n      \"pull\": \"Pull\",\n      \"quickSync\": \"Sincronização Rápida\"\n    },\n    \"vectorCalc\": {\n      \"tooltip\": \"Cálculo Vetorial: Cálculo automático 30s após a edição, ou clique para calcular agora\",\n      \"calculating\": \"Calculando\",\n      \"pending\": \"Pendente {progress}%\",\n      \"synced\": \"Sincronizado\"\n    }\n  },\n  \"quickRecord\": {\n    \"description\": \"Clique para selecionar uma ferramenta de registro e criar registros rapidamente\"\n  },\n  \"editor\": {\n    \"bubbleMenu\": {\n      \"ai\": \"IA\",\n      \"polish\": \"Polir\",\n      \"concise\": \"Conciso\",\n      \"expand\": \"Expandir\",\n      \"translate\": \"Traduzir\",\n      \"translateSubtitle\": \"Traduzir para\",\n      \"quoteToChat\": \"Citar no Chat\",\n      \"link\": \"Link\",\n      \"linkPlaceholder\": \"Digite a URL do link\",\n      \"confirm\": \"Confirmar\",\n      \"cancel\": \"Cancelar\",\n      \"bold\": \"Negrito\",\n      \"italic\": \"Itálico\",\n      \"strike\": \"Tachado\",\n      \"underline\": \"Sublinhado\",\n      \"inlineCode\": \"Código na linha\",\n      \"highlight\": \"Destacar\",\n      \"blockquote\": \"Citação\",\n      \"bulletList\": \"Lista com marcadores\",\n      \"orderedList\": \"Lista numerada\",\n      \"taskList\": \"Lista de tarefas\",\n      \"codeBlock\": \"Bloco de código\",\n      \"languages\": {\n        \"English\": \"Inglês\",\n        \"Japanese\": \"Japonês\",\n        \"Korean\": \"Coreano\",\n        \"French\": \"Francês\",\n        \"German\": \"Alemão\",\n        \"Spanish\": \"Espanhol\",\n        \"Portuguese\": \"Português\",\n        \"Russian\": \"Russo\",\n        \"Arabic\": \"Árabe\"\n      },\n      \"customLanguagePlaceholder\": \"Idioma personalizado...\"\n    },\n    \"aiSuggestion\": {\n      \"accept\": \"Aceitar\",\n      \"reject\": \"Rejeitar\",\n      \"generating\": \"Gerando...\",\n      \"abort\": \"Abortar\"\n    },\n    \"image\": {\n      \"insert\": \"Inserir imagem\",\n      \"uploading\": \"Enviando...\",\n      \"uploadSuccess\": \"Imagem enviada para o servidor de imagens\",\n      \"saveSuccess\": \"Imagem salva localmente\",\n      \"uploadFailed\": \"Falha ao inserir imagem\",\n      \"sizeSmall\": \"Pequeno (25%)\",\n      \"sizeMedium\": \"Médio (50%)\",\n      \"sizeLarge\": \"Grande (75%)\",\n      \"sizeOriginal\": \"Tamanho Original\",\n      \"editAlt\": \"Editar texto alternativo\",\n      \"editSrc\": \"Editar URL\",\n      \"altPlaceholder\": \"Digite o texto alternativo...\",\n      \"srcPlaceholder\": \"Digite a URL da imagem...\",\n      \"delete\": \"Excluir imagem\"\n    },\n    \"mermaid\": {\n      \"rendering\": \"Renderizando...\",\n      \"renderError\": \"Erro de renderização\",\n      \"clickToEdit\": \"Clique para editar código\",\n      \"clickToAdd\": \"Clique para adicionar diagrama\",\n      \"placeholder\": \"Digite o código do diagrama Mermaid...\",\n      \"preview\": \"Visualizar\",\n      \"done\": \"Concluído\",\n      \"diagramTypes\": {\n        \"flowchart\": \"Fluxograma\",\n        \"sequence\": \"Sequência\",\n        \"classDiagram\": \"Diagrama de Classes\",\n        \"stateDiagram\": \"Diagrama de Estados\",\n        \"er\": \"Diagrama ER\",\n        \"gantt\": \"Gantt\",\n        \"pie\": \"Gráfico de Pizza\",\n        \"journey\": \"Jornada\"\n      },\n      \"templates\": {\n        \"flowchart\": \"graph TD\\n    A[Início] --> B[Processo]\\n    B --> C[Fim]\",\n        \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: Olá\\n    Bob-->>Alice: Resposta\",\n        \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n        \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n        \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n        \"gantt\": \"gantt\\n    title Plano de Projeto\\n    dateFormat YYYY-MM-DD\\n    section Fase 1\\n    Tarefa1 :a1, 2024-01-01, 30d\\n    section Fase 2\\n    Tarefa2 :after a1, 20d\",\n        \"pie\": \"pie title Alocação de Recursos\\n    \\\"CPU\\\" : 45\\n    \\\"Memória\\\" : 30\\n    \\\"Armazenamento\\\" : 25\",\n        \"journey\": \"journey\\n    title Meu Trabalho Diário\\n    section Manhã\\n    Deslocamento : 7:00, 5\\n    Trabalho : 9:00, 8\"\n      }\n    },\n    \"slashCommand\": {\n      \"groups\": {\n        \"ai\": \"IA\",\n        \"heading\": \"Título\",\n        \"list\": \"Lista\",\n        \"block\": \"Bloco\",\n        \"align\": \"Alinhamento\",\n        \"embed\": \"Incorporar\",\n        \"math\": \"Matemática\",\n        \"chart\": \"Gráfico\"\n      },\n      \"items\": {\n        \"continue\": \"Continuar\",\n        \"continueDesc\": \"IA continua escrevendo o conteúdo\",\n        \"heading1\": \"Título 1\",\n        \"heading1Desc\": \"Título grande\",\n        \"heading2\": \"Título 2\",\n        \"heading2Desc\": \"Título médio\",\n        \"heading3\": \"Título 3\",\n        \"heading3Desc\": \"Título pequeno\",\n        \"bulletList\": \"Lista com Marcadores\",\n        \"bulletListDesc\": \"Criar uma lista com marcadores simples\",\n        \"orderedList\": \"Lista Ordenada\",\n        \"orderedListDesc\": \"Criar uma lista numerada\",\n        \"taskList\": \"Lista de Tarefas\",\n        \"taskListDesc\": \"Criar uma lista de tarefas com caixas de seleção\",\n        \"image\": \"Imagem\",\n        \"imageDesc\": \"Inserir imagem local ou hospedada\",\n        \"table\": \"Tabela\",\n        \"tableDesc\": \"Inserir uma tabela\",\n        \"blockquote\": \"Citação\",\n        \"blockquoteDesc\": \"Capturar conteúdo citado\",\n        \"codeBlock\": \"Bloco de Código\",\n        \"codeBlockDesc\": \"Capturar trechos de código\",\n        \"divider\": \"Divisor\",\n        \"dividerDesc\": \"Criar um divisor horizontal\",\n        \"inlineMath\": \"Math Inline\",\n        \"inlineMathDesc\": \"Inserir fórmula LaTeX inline\",\n        \"blockMath\": \"Math em Bloco\",\n        \"blockMathDesc\": \"Inserir fórmula LaTeX em bloco\",\n        \"flowchart\": \"Fluxograma\",\n        \"flowchartDesc\": \"Inserir um fluxograma\",\n        \"sequence\": \"Diagrama de Sequência\",\n        \"sequenceDesc\": \"Inserir um diagrama de sequência\",\n        \"gantt\": \"Gráfico de Gantt\",\n        \"ganttDesc\": \"Inserir um gráfico de Gantt\",\n        \"classDiagram\": \"Diagrama de Classes\",\n        \"classDiagramDesc\": \"Inserir um diagrama de classes\",\n        \"stateDiagram\": \"Diagrama de Estados\",\n        \"stateDiagramDesc\": \"Inserir um diagrama de estados\",\n        \"pie\": \"Gráfico de Pizza\",\n        \"pieDesc\": \"Inserir um gráfico de pizza\",\n        \"erDiagram\": \"Diagrama ER\",\n        \"erDiagramDesc\": \"Inserir um diagrama de entidade-relacionamento\",\n        \"journey\": \"Mapa de Jornada\",\n        \"journeyDesc\": \"Inserir um mapa de jornada do usuário\"\n      },\n      \"imageUpload\": {\n        \"success\": \"Upload bem-sucedido\",\n        \"saveSuccess\": \"Salvo com sucesso\",\n        \"savePath\": \"Salvo em: {path}\",\n        \"failed\": \"Falha ao inserir imagem\"\n      }\n    }\n  },\n  \"tabContext\": {\n    \"close\": \"Fechar\",\n    \"closeOthers\": \"Fechar Outros\",\n    \"closeAll\": \"Fechar Tudo\",\n    \"closeLeft\": \"Fechar à Esquerda\",\n    \"closeRight\": \"Fechar à Direita\"\n  }\n}\n"
  },
  {
    "path": "messages/zh-TW.json",
    "content": "{\n  \"app\": {\n    \"title\": \"筆記生成器\",\n    \"description\": \"你的 AI 驅動的筆記助手\"\n  },\n  \"common\": {\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"delete\": \"刪除\",\n    \"confirm\": \"確認\",\n    \"edit\": \"編輯\",\n    \"create\": \"創建\",\n    \"theme\": \"主題\",\n    \"light\": \"亮色\",\n    \"dark\": \"暗色\",\n    \"system\": \"跟隨系統\",\n    \"pin\": \"置頂\",\n    \"unpin\": \"取消置頂\",\n    \"settings\": \"設置\",\n    \"back\": \"返回\",\n    \"sync\": \"同步\",\n    \"language\": \"語言\",\n    \"success\": \"成功\",\n    \"error\": \"失敗\",\n    \"defaultFileName\": \"未命名文件\",\n    \"restartToApply\": \"，請重啟應用使配置生效\",\n    \"close\": \"關閉\",\n    \"open\": \"打開\",\n    \"add\": \"添加\",\n    \"remove\": \"移除\",\n    \"search\": \"搜索\",\n    \"filter\": \"篩選\",\n    \"sort\": \"排序\",\n    \"export\": \"導出\",\n    \"import\": \"導入\",\n    \"refresh\": \"刷新\",\n    \"loading\": \"加載中...\",\n    \"warning\": \"警告\",\n    \"info\": \"信息\",\n    \"unsaved\": \"未保存\",\n    \"saving\": \"保存中...\",\n    \"configureSync\": \"配置同步\"\n  },\n  \"settings\": {\n    \"defaultModels\": {\n      \"title\": \"預設模型\"\n    },\n    \"others\": \"高級\",\n    \"general\": {\n      \"title\": \"常規設置\",\n      \"desc\": \"在這裡，你可以配置應用的基本設置，包括界面主題、語言等選項。\",\n      \"interface\": {\n        \"title\": \"界面設置\",\n        \"theme\": {\n          \"title\": \"主題\",\n          \"desc\": \"選擇應用的外觀主題\",\n          \"options\": {\n            \"light\": \"亮色\",\n            \"dark\": \"暗色\",\n            \"system\": \"跟隨系統\"\n          }\n        },\n        \"language\": {\n          \"title\": \"語言\",\n          \"desc\": \"選擇應用的顯示語言\"\n        },\n        \"scale\": {\n          \"title\": \"界面縮放\",\n          \"desc\": \"調整應用界面的整體縮放比例\",\n          \"placeholder\": \"選擇縮放比例\"\n        },\n        \"contentTextScale\": {\n          \"title\": \"正文縮放\",\n          \"desc\": \"調整編輯器和對話中 Markdown 內容的文字大小\"\n        },\n        \"fileManagerTextSize\": {\n          \"title\": \"文件管理器文字大小\",\n          \"desc\": \"調整文件管理器中文件和文件夾列表的文字大小\"\n        },\n        \"recordTextSize\": {\n          \"title\": \"記錄文字大小\",\n          \"desc\": \"調整記錄列表中記錄項的文字大小\"\n        },\n        \"customCss\": {\n          \"title\": \"自訂 CSS\",\n          \"desc\": \"添加自訂 CSS 樣式來覆蓋應用的默認樣式\",\n          \"button\": \"編輯 CSS\",\n          \"dialogTitle\": \"自訂 CSS\",\n          \"dialogDesc\": \"在下方輸入自訂 CSS 代碼，可以覆蓋應用的默認樣式。修改後點擊保存即可生效。\",\n          \"placeholder\": \"在此輸入自訂 CSS 代碼。\",\n          \"save\": \"保存\",\n          \"cancel\": \"取消\"\n        },\n        \"tray\": {\n          \"enabled\": {\n            \"title\": \"啟用托盤\",\n            \"desc\": \"關閉窗口時選擇最小化到托盤或直接關閉軟件\"\n          }\n        },\n        \"customTheme\": {\n          \"title\": \"自定义主题颜色\",\n          \"desc\": \"自定义应用的主题颜色，包括背景色、前景色、边框色等\",\n          \"button\": \"编辑颜色\",\n          \"dialogTitle\": \"自定义主题颜色\",\n          \"dialogDesc\": \"配置自定义主题颜色。颜色更改会实时保存并生效，同时覆盖亮色和暗色主题。\",\n          \"close\": \"關閉\",\n          \"reset\": \"重置全部\",\n          \"tabs\": {\n            \"custom\": \"自定义\",\n            \"presets\": \"预设方案\",\n            \"importExport\": \"导入导出\"\n          },\n          \"export\": {\n            \"title\": \"导出配色方案\",\n            \"button\": \"生成导出代码\",\n            \"placeholder\": \"点击生成按钮将当前配色导出为代码\"\n          },\n          \"import\": {\n            \"title\": \"导入配色方案\",\n            \"button\": \"导入配色\",\n            \"placeholder\": \"粘贴配色方案的 JSON 代码\"\n          },\n          \"colors\": {\n            \"background\": \"背景色\",\n            \"foreground\": \"前景色\",\n            \"card\": \"卡片背景色\",\n            \"cardForeground\": \"卡片前景色\",\n            \"primary\": \"主色调\",\n            \"primaryForeground\": \"主色调前景色\",\n            \"secondary\": \"次要色调\",\n            \"secondaryForeground\": \"次要色调前景色\",\n            \"third\": \"第三色调\",\n            \"thirdForeground\": \"第三色调前景色\",\n            \"muted\": \"柔和色\",\n            \"mutedForeground\": \"柔和色前景色\",\n            \"accent\": \"强调色\",\n            \"accentForeground\": \"强调色前景色\",\n            \"border\": \"边框色\",\n            \"shadow\": \"阴影色\"\n          },\n          \"presets\": {\n            \"apply\": \"应用\",\n            \"reset\": {\n              \"name\": \"恢复默认\"\n            },\n            \"default\": {\n              \"name\": \"默认白色\"\n            },\n            \"ocean\": {\n              \"name\": \"海洋蓝\"\n            },\n            \"forest\": {\n              \"name\": \"森林绿\"\n            },\n            \"sunset\": {\n              \"name\": \"日落红\"\n            },\n            \"lavender\": {\n              \"name\": \"薰衣草紫\"\n            },\n            \"midnight\": {\n              \"name\": \"午夜暗\"\n            },\n            \"deepSea\": {\n              \"name\": \"深海蓝\"\n            },\n            \"darkForest\": {\n              \"name\": \"暗夜绿\"\n            },\n            \"darkViolet\": {\n              \"name\": \"紫罗兰暗\"\n            },\n            \"coralWarm\": {\n              \"name\": \"珊瑚暖\"\n            },\n            \"slateGray\": {\n              \"name\": \"石板灰\"\n            },\n            \"darkGold\": {\n              \"name\": \"暗夜金\"\n            },\n            \"beigeWarm\": {\n              \"name\": \"米黄暖\"\n            },\n            \"beigeDark\": {\n              \"name\": \"米黄暗\"\n            }\n          }\n        }\n      },\n      \"tools\": {\n        \"title\": \"工具設置\",\n        \"chatToolbar\": {\n          \"title\": \"對話工具欄\",\n          \"desc\": \"自訂對話工具欄按鈕的顯示順序和可見性，打造個性化的對話體驗\",\n          \"button\": \"設置\",\n          \"dialogTitle\": \"配置對話工具欄\",\n          \"dialogDesc\": \"拖動工具調整排序，使用開關控制顯示或隱藏\",\n          \"groups\": {\n            \"pc\": \"PC 端\",\n            \"mobile\": \"移動端\",\n            \"bottom\": \"底部工具欄\",\n            \"topLeft\": \"頂部工具欄 - 左側\",\n            \"topRight\": \"頂部工具欄 - 右側\"\n          }\n        },\n        \"desc\": \"配置各种工具栏按钮的显示和排序\",\n        \"recordToolbar\": {\n          \"title\": \"记录工具栏\",\n          \"desc\": \"自定义记录工具栏按钮的显示顺序和可见性\",\n          \"button\": \"設置\",\n          \"dialogTitle\": \"配置记录工具栏\",\n          \"dialogDesc\": \"拖动工具调整排序，使用开关控制显示或隐藏\"\n        }\n      }\n    },\n    \"rag\": {\n      \"title\": \"知識庫\",\n      \"desc\": \"在這裡，你可以配置知識庫相關設置，知識庫基於 RAG 技術，透過嵌入模型將文本轉換為向量，然後透過向量搜索來實現智慧搜索和智慧回答。\",\n      \"settingsTitle\": \"參數設置\",\n      \"settingsDesc\": \"通過調解參數，可以更加精確的控制知識庫的檢索效果。\",\n      \"deleteVectorConfirm\": \"確定清空知識庫嗎？\",\n      \"deleteVectorSuccess\": \"清空知識庫成功\",\n      \"enable\": \"啟用知識庫檢索\",\n      \"enableDesc\": \"啟用後，AI 將在回答問題時檢索你的筆記內容，提供更準確的回答。\",\n      \"topPDesc\": \"Top P 參數控制模型生成文本的多樣性，值越小生成的文本越確定，值越大生成的文本越多樣。\",\n      \"chunkSize\": \"分塊大小\",\n      \"chunkSizeDesc\": \"文本分塊的最大字元數，較大的分塊可能包含更多上下文，但會增加向量計算的複雜度。\",\n      \"chunkOverlap\": \"重疊大小\",\n      \"chunkOverlapDesc\": \"文本分塊間的重疊字元數，較大的重疊可以保持上下文連貫性。\",\n      \"resultCount\": \"檢索數量\",\n      \"resultCountDesc\": \"檢索時返回的相關文件數量，數量越多提供的資訊可能更豐富，但也可能引入噪聲。\",\n      \"similarityThreshold\": \"相似度閾值\",\n      \"similarityThresholdDesc\": \"文件與查詢的最小相似度閾值，只有超過此閾值的文件才會被返回。值範圍 0.0-1.0，越高要求越嚴格。\",\n      \"resetToDefaults\": \"重設預設值\",\n      \"deleteVector\": \"清空知識庫\"\n    },\n    \"mcp\": {\n      \"title\": \"MCP\",\n      \"desc\": \"Model Context Protocol 允許 AI 調用外部工具和訪問資源，擴展 AI 的能力邊界。\",\n      \"servers\": \"伺服器列表\",\n      \"serversDesc\": \"管理 MCP 伺服器配置，每個伺服器可以提供不同的工具和資源。\",\n      \"addServer\": \"添加伺服器\",\n      \"addFirstServer\": \"添加第一個伺服器\",\n      \"editServer\": \"編輯伺服器\",\n      \"serverName\": \"伺服器名稱\",\n      \"serverNamePlaceholder\": \"例如：文件系統伺服器\",\n      \"serverEnabled\": \"啟用伺服器\",\n      \"serverEnabledDesc\": \"啟用後，此伺服器將自動連接並提供工具。\",\n      \"serverType\": \"伺服器類型\",\n      \"stdio\": \"本地命令\",\n      \"http\": \"HTTP 服務\",\n      \"command\": \"命令\",\n      \"args\": \"參數\",\n      \"argsDesc\": \"命令行參數，用空格分隔\",\n      \"env\": \"環境變數\",\n      \"envDesc\": \"JSON 格式的環境變數配置\",\n      \"url\": \"服務地址\",\n      \"headers\": \"請求頭\",\n      \"headersDesc\": \"JSON 格式的 HTTP 請求頭\",\n      \"testConnection\": \"測試連接\",\n      \"test\": \"測試\",\n      \"testSuccess\": \"連接測試成功\",\n      \"testFailed\": \"連接測試失敗\",\n      \"connected\": \"已連接\",\n      \"connecting\": \"連線中\",\n      \"disconnected\": \"未連接\",\n      \"error\": \"錯誤\",\n      \"tools\": \"工具\",\n      \"noServers\": \"未啟用 MCP 服務功能\",\n      \"noServersFound\": \"未找到匹配的伺服器\",\n      \"serverAdded\": \"伺服器添加成功\",\n      \"serverUpdated\": \"伺服器更新成功\",\n      \"serverDeleted\": \"伺服器刪除成功\",\n      \"deleteServerTitle\": \"刪除伺服器\",\n      \"deleteServerDesc\": \"確定要刪除這個伺服器嗎？此操作無法撤銷。\",\n      \"nameRequired\": \"請輸入伺服器名稱\",\n      \"commandRequired\": \"請輸入命令\",\n      \"urlRequired\": \"請輸入服務地址\",\n      \"toolBrowser\": \"工具瀏覽器\",\n      \"searchTools\": \"搜索工具...\",\n      \"noToolsFound\": \"未找到工具\",\n      \"parameters\": \"參數\",\n      \"testAll\": \"測試所有連接\",\n      \"testAllCompleted\": \"所有連接測試完成\",\n      \"testAllFailed\": \"連接測試失敗\",\n      \"save\": \"保存\",\n      \"cancel\": \"取消\",\n      \"delete\": \"刪除\",\n      \"importJson\": \"導入 JSON\",\n      \"jsonImportTitle\": \"從 JSON 導入伺服器配置\",\n      \"jsonImportDesc\": \"粘貼 MCP 伺服器的 mcpServers 配置格式\",\n      \"jsonInput\": \"JSON 配置\",\n      \"jsonInputHelp\": \"支持 mcpServers 格式，會自動使用伺服器名稱作為 key\",\n      \"jsonRequired\": \"請輸入 JSON 配置\",\n      \"jsonEmpty\": \"JSON 配置不能為空\",\n      \"jsonInvalidJson\": \"JSON 格式錯誤\",\n      \"jsonInvalidFormat\": \"配置格式無效，必須包含 name 和 type 字段\",\n      \"jsonInvalidType\": \"伺服器類型必須是 stdio 或 http\",\n      \"jsonMissingCommand\": \"stdio 類型伺服器必須指定 command\",\n      \"jsonMissingUrl\": \"http 類型伺服器必須指定 url\",\n      \"jsonImportSuccess\": \"成功導入 {count} 個伺服器\",\n      \"jsonImportSkipped\": \"跳過 {count} 個已存在的伺服器\",\n      \"jsonImportNoServers\": \"沒有導入任何伺服器\",\n      \"import\": \"導入\",\n      \"mobileHttpOnlyTitle\": \"本地命令 MCP 僅限桌面端\",\n      \"mobileHttpOnlyDesc\": \"本地命令型 MCP 伺服器僅支援桌面端，行動端目前只支援 HTTP MCP。\",\n      \"runtimeEnvironment\": \"執行環境\",\n      \"runtimeEnvironmentDesc\": \"在測試 MCP 伺服器前，先檢查所需的本地執行環境是否可用。\",\n      \"checkEnvironment\": \"檢查環境\",\n      \"recheckEnvironment\": \"重新檢查環境\",\n      \"runtimeCheckFailed\": \"環境檢查失敗\",\n      \"detectedLauncher\": \"偵測到的啟動器\",\n      \"runtimeInstalled\": \"已安裝\",\n      \"runtimeMissing\": \"缺失\",\n      \"runtimeVersion\": \"版本\",\n      \"runtimeInstalledSummary\": \"已安裝 {installed}/{total}\",\n      \"showRuntimeDetails\": \"展開執行環境詳情\",\n      \"hideRuntimeDetails\": \"收起執行環境詳情\",\n      \"runtimeNotChecked\": \"尚未檢查此執行環境。\",\n      \"runtimeCurrentUserScope\": \"若支援，建議命令會安裝到目前使用者環境。\",\n      \"runtimeManualOnly\": \"目前平台暫不支援自動安裝此執行環境，請手動安裝後重新檢查。\",\n      \"installRuntime\": \"安裝執行環境\",\n      \"runtimeInstallTitle\": \"安裝執行環境\",\n      \"runtimeInstallDesc\": \"確認後，NoteGen 將執行以下安裝命令。\",\n      \"runtimeInstallPreparing\": \"準備安裝\",\n      \"runtimeInstallRunning\": \"安裝中\",\n      \"runtimeInstallCompleted\": \"安裝完成\",\n      \"runtimeInstallCancelled\": \"已取消\",\n      \"runtimeInstallFailedState\": \"安裝失敗\",\n      \"runtimeInstallLogs\": \"安裝日誌\",\n      \"runtimeInstallWaitingLogs\": \"等待安裝輸出...\",\n      \"runtimeInstallClose\": \"關閉\",\n      \"runtimeInstallCancel\": \"終止安裝\",\n      \"runtimeInstallCancelledByUser\": \"使用者已要求取消安裝。\",\n      \"runtimeInstallCancelFailed\": \"終止安裝失敗\",\n      \"runtimeInstallSuccess\": \"執行環境安裝完成\",\n      \"runtimeInstallFailed\": \"執行環境安裝失敗\",\n      \"runtimeNoGuidedSupport\": \"目前此命令尚未提供引導式執行環境輔助。\",\n      \"enableTitle\": \"启用 MCP 功能\",\n      \"enableDesc\": \"启用后，AI 可以调用配置的 MCP 服务器提供的工具。\"\n    },\n    \"editor\": {\n      \"title\": \"編輯器設置\",\n      \"interfaceSettings\": \"界面設置\",\n      \"desc\": \"在這裡，你可以對編輯器進行自定義配置，打造更適合你的寫作方式。\",\n      \"centeredContent\": \"置中內容\",\n      \"centeredContentDesc\": \"啟用後，編輯器內容將在中間顯示，兩側留白。\",\n      \"outlineEnable\": \"預設啟用大綱\",\n      \"outlineEnableDesc\": \"啟用後，編輯器將默認顯示大綱。\",\n      \"outlinePosition\": \"大綱位置\",\n      \"outlinePositionDesc\": \"設置大綱位置。\",\n      \"outlinePositionOptions\": {\n        \"left\": \"左側\",\n        \"right\": \"右側\"\n      },\n      \"showUndoRedo\": \"復原/重做按鈕\",\n      \"showUndoRedoDesc\": \"在編輯器標籤列顯示復原和重做按鈕。\",\n      \"completion\": {\n        \"title\": \"快速補全\",\n        \"model\": {\n          \"title\": \"快速補全模型\",\n          \"desc\": \"選擇用於編輯器 AI 內聯補全的模型\"\n        }\n      },\n      \"commit\": {\n        \"title\": \"自動生成 Commit\",\n        \"model\": {\n          \"title\": \"提交模型\",\n          \"desc\": \"用於自動生成 Git 提交信息，基於文件內容變化智能生成描述性提交信息\"\n        }\n      },\n      \"mermaid\": {\n        \"title\": \"圖表\",\n        \"rendering\": \"渲染中...\",\n        \"renderError\": \"渲染錯誤\",\n        \"clickToEdit\": \"點擊編輯源碼\",\n        \"clickToAdd\": \"點擊添加圖表\",\n        \"placeholder\": \"輸入 Mermaid 圖表代碼...\",\n        \"preview\": \"預覽\",\n        \"done\": \"完成\",\n        \"diagramTypes\": {\n          \"flowchart\": \"流程圖\",\n          \"sequence\": \"時序圖\",\n          \"classDiagram\": \"類圖\",\n          \"stateDiagram\": \"狀態圖\",\n          \"er\": \"ER圖\",\n          \"gantt\": \"甘特圖\",\n          \"pie\": \"餅圖\",\n          \"journey\": \"旅程圖\"\n        },\n        \"templates\": {\n          \"flowchart\": \"graph TD\\n    A[開始] --> B[處理]\\n    B --> C[結束]\",\n          \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: 你好\\n    Bob-->>Alice: 回覆\",\n          \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n          \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n          \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n          \"gantt\": \"gantt\\n    title 項目計劃\\n    dateFormat YYYY-MM-DD\\n    section 第一階段\\n    任務1 :a1, 2024-01-01, 30d\\n    section 第二階段\\n    任務2 :after a1, 20d\",\n          \"pie\": \"pie title 資源分配\\n    \\\"CPU\\\" : 45\\n    \\\"記憶體\\\" : 30\\n    \\\"儲存\\\" : 25\",\n          \"journey\": \"journey\\n    title 我的日常工作\\n    section 上午\\n    通勤 : 7:00, 5\\n    工作 : 9:00, 8\"\n        }\n      }\n    },\n    \"record\": {\n      \"title\": \"記錄設置\",\n      \"desc\": \"在這裡，你可以配置記錄相關的設置，包括記錄描述和工具欄配置。\",\n      \"model\": {\n        \"title\": \"模型設置\",\n        \"markDesc\": {\n          \"title\": \"記錄描述\",\n          \"desc\": \"用於處理 OCR 識別後的記錄，生成記錄描述\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"工具欄設置\",\n        \"recordToolbar\": {\n          \"title\": \"記錄工具欄\",\n          \"desc\": \"自定義記錄工具欄按鈕的顯示順序和可見性\",\n          \"button\": \"設置\",\n          \"text\": {\n            \"desc\": \"记录文本内容\"\n          },\n          \"recording\": {\n            \"desc\": \"录音记录功能\"\n          },\n          \"scan\": {\n            \"desc\": \"扫描识别图片中的文字\"\n          },\n          \"image\": {\n            \"desc\": \"上传图片到笔记\"\n          },\n          \"link\": {\n            \"desc\": \"记录网页链接\"\n          },\n          \"file\": {\n            \"desc\": \"上传文件到笔记\"\n          },\n          \"todo\": {\n            \"desc\": \"创建待办事项\"\n          }\n        }\n      }\n    },\n    \"uploadStore\": {\n      \"uploadConfirm\": \"上傳配置請確保同步倉庫為私有，否則數據將會洩露！\",\n      \"downloadConfirm\": \"下載配置將會覆蓋本地配置，並且重啟生效！\",\n      \"uploadSuccess\": \"上傳成功\",\n      \"downloadSuccess\": \"下載成功\",\n      \"upload\": \"上傳配置\",\n      \"download\": \"下載配置\"\n    },\n    \"prompt\": {\n      \"title\": \"Prompt\",\n      \"promptTitle\": \"Prompt 名稱\",\n      \"desc\": \"在這裡，你可以添加和管理 Prompt，幫助 AI 更好地理解你的需求。\",\n      \"addPrompt\": \"新增 Prompt\",\n      \"selectPrompt\": \"選擇 Prompt\",\n      \"configPrompt\": \"配置 Prompt\",\n      \"noContent\": \"暫無內容\",\n      \"addPromptDesc\": \"請輸入 Prompt 名稱和內容，幫助AI更好地理解你的需求。\",\n      \"promptTitlePlaceholder\": \"請輸入 Prompt 名稱\",\n      \"promptContentPlaceholder\": \"請輸入 Prompt 內容\",\n      \"promptContent\": \"Prompt 內容\",\n      \"optimizePrompt\": \"最佳化提示詞\",\n      \"optimizing\": \"最佳化中...\",\n      \"optimizeSuccess\": \"提示詞最佳化成功\",\n      \"optimizeFailed\": \"提示詞最佳化失敗，請稍後重試\",\n      \"noContentToOptimize\": \"請先輸入提示詞內容\"\n    },\n    \"memories\": {\n      \"title\": \"記憶管理\",\n      \"desc\": \"AI 長期記憶功能，讓 AI 記住你的寫作偏好、知識體系和筆記習慣。\",\n      \"stats\": {\n        \"total\": \"總記憶數\",\n        \"preferences\": \"偏好\",\n        \"knowledge\": \"知識\",\n        \"memories\": \"记忆\"\n      },\n      \"form\": {\n        \"title\": \"新增記憶\",\n        \"contentLabel\": \"記憶內容\",\n        \"contentPlaceholder\": \"例如：我喜歡用中文回答、我是React專家...\",\n        \"categoryLabel\": \"類型\",\n        \"preferenceDesc\": \"偏好（語言、格式、風格等）\",\n        \"knowledgeDesc\": \"知識（事實、經驗、專長等）\",\n        \"save\": \"儲存記憶\",\n        \"saving\": \"儲存中...\",\n        \"categoryDescription\": \"记忆分为两种类型：\",\n        \"preferenceDescription\": \"偏好：语言、格式、风格等设置，每次对话都会自动加载\",\n        \"memoryDescription\": \"记忆：事实、经验、专长等信息，根据对话内容智能匹配\",\n        \"preferenceLabel\": \"偏好\",\n        \"memoryLabel\": \"记忆\",\n        \"memoryDesc\": \"事实、经验、专长等\"\n      },\n      \"listTitle\": \"我的記憶\",\n      \"addMemory\": \"新增記憶\",\n      \"empty\": \"暫無記憶，新增第一條記憶吧！\",\n      \"emptyHint\": \"你可以手動新增記憶，或在對話中使用「請記住」「記住這個」等話術讓 AI 自動記憶。\",\n      \"preference\": \"偏好\",\n      \"knowledge\": \"知識\",\n      \"replaced\": \"已替換\",\n      \"accessCount\": \"存取 {count} 次\",\n      \"tabs\": {\n        \"all\": \"全部\",\n        \"preference\": \"偏好\",\n        \"knowledge\": \"知識\",\n        \"memory\": \"记忆\"\n      },\n      \"success\": \"成功\",\n      \"saved\": \"記憶已儲存\",\n      \"updated\": \"記憶已更新（已替換相似記憶）\",\n      \"deleted\": \"記憶已刪除\",\n      \"cleared\": \"所有記憶已清空\",\n      \"found\": \"找到 {count} 條記憶\",\n      \"error\": \"錯誤\",\n      \"errorEmpty\": \"請輸入記憶內容\",\n      \"errorSave\": \"儲存失敗\",\n      \"errorDelete\": \"刪除失敗\",\n      \"errorList\": \"取得記憶列表失敗\",\n      \"errorEmbedding\": \"無法生成向量嵌入，請檢查嵌入模型設定\",\n      \"errorClear\": \"清空失敗\",\n      \"memory\": \"记忆\"\n    },\n    \"defaultModel\": {\n      \"title\": \"默認模型\",\n      \"desc\": \"在這裡，你可以針對不同的場景使用不同的模型，提高效率降低成本。\",\n      \"tooltip\": \"使用主要模型\",\n      \"noModel\": \"不使用模型\",\n      \"placeholder\": \"請選擇或搜索模型\",\n      \"mainModel\": \"主要模型\",\n      \"options\": {\n        \"primaryModel\": {\n          \"title\": \"主要模型\",\n          \"desc\": \"作為所有場景的主要模型，如果其他對話模型未選擇默認模型，則使用此模型。\"\n        },\n        \"markDesc\": {\n          \"title\": \"記錄描述\",\n          \"desc\": \"用於處理 OCR 識別後的紀錄，生成記錄描述。\"\n        },\n        \"placeholder\": {\n          \"title\": \"AI 建議\",\n          \"desc\": \"AI 建議提示作用於記錄頁面 AI 對話 placeholder 內容生成。\"\n        },\n        \"completion\": {\n          \"title\": \"快速補全\",\n          \"desc\": \"用於 Markdown 編輯器的 AI 內聯補全，類似 GitHub Copilot，快速生成續寫內容。\"\n        },\n        \"commit\": {\n          \"title\": \"自動生成 commit 資訊\",\n          \"desc\": \"用於自動生成 Git 提交資訊，基於文件內容變化智能生成描述性提交資訊。\"\n        },\n        \"embedding\": {\n          \"title\": \"嵌入模型\",\n          \"desc\": \"用於處理文本嵌入和向量化的場景。\"\n        },\n        \"reranking\": {\n          \"title\": \"重排模型\",\n          \"desc\": \"用於搜索結果的重新排序和最佳化。\"\n        },\n        \"condense\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"用于压缩历史对话内容，节省 token 使用量\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"朗讀\",\n      \"desc\": \"在這裡，你可以配置朗讀相關設置，為聊天內容提供語音播放功能。\",\n      \"noModel\": \"不使用模型\",\n      \"alert\": {\n        \"title\": \"你正在使用系統朗讀\",\n        \"description\": \"當前未配置音訊模型，正在使用系統朗讀。\"\n      },\n      \"options\": {\n        \"audioModel\": {\n          \"title\": \"音訊模型\",\n          \"desc\": \"選擇用於文本轉語音的AI模型，支持多種語音類型和參數配置。\"\n        },\n        \"speed\": {\n          \"title\": \"語速\",\n          \"desc\": \"調整語音播放的速度，範圍從0.25倍到4倍速度，預設為1倍正常速度。\"\n        }\n      }\n    },\n    \"about\": {\n      \"title\": \"關於\",\n      \"desc\": \"一款專注於記錄與寫作的 AI 筆記。\",\n      \"version\": \"NoteGen v{version}\",\n      \"checkReleases\": \"查詢歷史版本\",\n      \"language\": \"語言\",\n      \"checkUpdate\": \"檢查更新\",\n      \"checkError\": \"檢查更新失敗\",\n      \"updateAvailable\": \"更新至最新版本\",\n      \"updateDownloading\": \"更新中 {downloaded} / {contentLength}\",\n      \"updateInstalled\": \"重啟應用\",\n      \"noUpdate\": \"當前已是最新版本\",\n      \"ignoreVersion\": \"忽略此版本\",\n      \"ignoreVersionSuccess\": \"已忽略此版本更新\",\n      \"items\": {\n        \"home\": {\n          \"title\": \"官網\",\n          \"buttonName\": \"打開\",\n          \"desc\": \"訪問官網，了解 NoteGen 的更多資訊。\"\n        },\n        \"guide\": {\n          \"title\": \"配置指南\",\n          \"buttonName\": \"打開\",\n          \"desc\": \"查看配置指南，了解如何配置模型、同步等資訊。\"\n        },\n        \"github\": {\n          \"title\": \"GitHub\",\n          \"buttonName\": \"查看\",\n          \"desc\": \"如果 NoteGen 幫助到了你，請給顆 star 鼓勵一下！\"\n        },\n        \"releases\": {\n          \"title\": \"更新日誌\",\n          \"buttonName\": \"查看\",\n          \"desc\": \"查看更新日誌，了解 NoteGen 的更新資訊。\"\n        },\n        \"issues\": {\n          \"title\": \"問題回饋\",\n          \"buttonName\": \"回饋\",\n          \"desc\": \"如果發現 NoteGen 有 bug，請在這裡回饋。\"\n        },\n        \"discussions\": {\n          \"title\": \"交流討論\",\n          \"buttonName\": \"討論\",\n          \"desc\": \"如果你想和作者或其他用戶交流，可以加群討論。\"\n        }\n      }\n    },\n    \"sync\": {\n      \"title\": \"同步配置\",\n      \"desc\": \"在這裡，你可以配置同步倉庫，它可以幫助你同步記錄、markdown 文件、系統配置等資訊。\",\n      \"selectPlatform\": \"選擇同步平台\",\n      \"platformSettings\": \"選擇平台\",\n      \"settings\": \"同步設置\",\n      \"platformDesc\": \"配置 Token 和倉庫資訊以啟用同步功能\",\n      \"moreSettings\": \"更多設置\",\n      \"repoStatus\": \"倉庫狀態\",\n      \"syncRepo\": \"同步倉庫\",\n      \"syncRepoDesc\": \"同步寫作中的 markdown 文件。\",\n      \"imageRepo\": \"圖床倉庫\",\n      \"imageRepoDesc\": \"同步你的圖片到圖床倉庫。\",\n      \"status\": {\n        \"connected\": \"已連接\",\n        \"disconnected\": \"未連接\",\n        \"failed\": \"連線失敗\",\n        \"unconfigured\": \"未配置\"\n      },\n      \"uploadRecords\": \"上載記錄和配置\",\n      \"downloadConfig\": \"下載記錄和配置\",\n      \"cloudSync\": \"記錄與配置同步\",\n      \"localBackupAll\": \"本地備份（全部）\",\n      \"private\": \"私有\",\n      \"public\": \"公開\",\n      \"createdAt\": \"創建於 {time}\",\n      \"updatedAt\": \"最後更新於 {time}\",\n      \"newToken\": \"創建 access token\",\n      \"newTokenDesc\": \"新建 token 時，請務必勾選 repo 權限，配置後將自動創建文件倉庫（私有）和圖床倉庫。\",\n      \"giteeTokenDesc\": \"Gitee 私人令牌用於同步數據，需要有倉庫的讀寫權限，配置後將自動創建文件倉庫（私有）和圖床倉庫。\",\n      \"imageRepoSetting\": \"開啟圖床\",\n      \"imageRepoSettingDesc\": \"你已經配置了圖床倉庫，開啟此項將使用圖床倉庫，否則將使用本地儲存。\",\n      \"jsdelivrSetting\": \"jsDelivr\",\n      \"jsdelivrSettingDesc\": \"使用 jsdelivr 加速圖片訪問。\",\n      \"autoSyncDesc\": \"啟用後，編輯器會在輸入停止 10 秒後自動同步到 GitHub\",\n      \"giteeAutoSyncDesc\": \"啟用後，編輯器會在輸入停止 10 秒後自動同步到 Gitee\",\n      \"customSyncRepo\": \"自訂同步倉庫名\",\n      \"customSyncRepoDesc\": \"留空則使用默認倉庫名\",\n      \"customImageRepo\": \"自訂圖床倉庫名\",\n      \"customImageRepoDesc\": \"留空則使用默認倉庫名\",\n      \"backupMethod\": \"備份方式\",\n      \"backupMethodDesc\": \"設置為主要備份方式後，寫作中的所有同步相關功能將使用當前備份方式（圖床功能除外）\",\n      \"createRepo\": \"創建倉庫\",\n      \"creating\": \"創建中\",\n      \"checkRepo\": \"檢查倉庫\",\n      \"checking\": \"檢查中\",\n      \"enterToken\": \"請輸入 Access Token\",\n      \"enterTokenHint\": \"請先輸入 Access Token 以檢查倉庫狀態\",\n      \"defaultRepoName\": \"默認: {name}\",\n      \"gitlabInstanceType\": \"GitLab 實例類型\",\n      \"gitlabInstanceTypeDesc\": \"選擇要連接的 GitLab 實例類型\",\n      \"gitlabInstanceTypePlaceholder\": \"選擇 GitLab 實例類型\",\n      \"gitlabInstanceTypeOptions\": {\n        \"selfHosted\": \"自建實例\",\n        \"selfHostedDesc\": \"輸入您的自建 GitLab 伺服器地址（如：https://gitlab.example.com）\"\n      },\n      \"gitlabAccessTokenDesc\": \"在 {instanceDisplayName} 創建個人訪問令牌，需要 api 權限\",\n      \"autoSync\": \"自動同步\",\n      \"autoSyncOptions\": {\n        \"placeholder\": \"選擇自動同步時間\",\n        \"disabled\": \"關閉\",\n        \"2s\": \"2 秒\",\n        \"3s\": \"3 秒\",\n        \"5s\": \"5 秒\",\n        \"10s\": \"10 秒\",\n        \"20s\": \"20 秒\",\n        \"30s\": \"30 秒\",\n        \"1m\": \"1 分鐘\",\n        \"2m\": \"2 分鐘\"\n      },\n      \"autoPullOnOpen\": \"開啟檔案時自動拉取\",\n      \"autoPullOnOpenDesc\": \"開啟檔案時，若遠端有新版本則自動拉取覆蓋本地\",\n      \"autoPullOnSwitch\": \"切換檔案時自動拉取\",\n      \"autoPullOnSwitchDesc\": \"切換到其他檔案時，若遠端有新版本則自動拉取覆蓋本地\",\n      \"exclusions\": {\n        \"title\": \"同步排除配置\",\n        \"desc\": \"以下配置項不會在設備間同步，因為它們是設備特定的\",\n        \"workspacePath\": \"工作區路徑\",\n        \"workspaceHistory\": \"工作區歷史路徑\",\n        \"assetsPath\": \"資源路徑\",\n        \"uiScale\": \"界面縮放\",\n        \"contentTextScale\": \"正文文字縮放\",\n        \"customCss\": \"自訂 CSS\",\n        \"reason\": \"這些配置在不同設備上可能不同，不進行同步可以避免路徑錯誤等問題\"\n      },\n      \"settingsSync\": {\n        \"uploadSuccess\": \"配置上傳成功\",\n        \"uploadFailed\": \"配置上傳失敗\",\n        \"downloadSuccess\": \"配置下載成功\",\n        \"downloadFailed\": \"配置下載失敗\",\n        \"autoSync\": \"上傳/下載時會自動同步配置（排除工作區路徑等設備特定配置）\"\n      },\n      \"giteaInstanceType\": \"Gitea 实例类型\",\n      \"giteaInstanceTypeDesc\": \"选择要连接的 Gitea 实例类型\",\n      \"giteaInstanceTypePlaceholder\": \"选择 Gitea 实例类型\",\n      \"giteaInstanceTypeOptions\": {\n        \"selfHosted\": \"自建实例\",\n        \"selfHostedDesc\": \"输入您的自建 Gitea 服务器地址（如：https://gitea.example.com）\"\n      },\n      \"giteaAccessTokenDesc\": \"在 {instanceDisplayName} 创建个人访问令牌，需要完整的 repository 权限\",\n      \"s3\": {\n        \"title\": \"S3 同步\",\n        \"description\": \"使用 S3 兼容存储同步你的笔记\",\n        \"status\": \"连接状态\",\n        \"connected\": \"已连接\",\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"未连接\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"请输入 Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"请输入 Secret Access Key\",\n        \"region\": \"区域\",\n        \"bucket\": \"存储桶\",\n        \"bucketPlaceholder\": \"请输入存储桶名称\",\n        \"endpoint\": \"端点\",\n        \"pathPrefix\": \"路径前缀\",\n        \"pathPrefixPlaceholder\": \"请输入路径前缀\",\n        \"pathPrefixDesc\": \"用于区分不同用户的文件，类似仓库名\",\n        \"customDomain\": \"自定义域名\",\n        \"testConnection\": \"测试连接\",\n        \"testing\": \"测试中\",\n        \"saveConfig\": \"保存配置\",\n        \"saving\": \"保存中\"\n      },\n      \"webdav\": {\n        \"title\": \"WebDAV 同步\",\n        \"description\": \"使用 WebDAV 协议同步你的笔记\",\n        \"status\": \"连接状态\",\n        \"connected\": \"已连接\",\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"未连接\",\n        \"url\": \"服务器地址\",\n        \"urlPlaceholder\": \"请输入 WebDAV 服务器地址\",\n        \"urlDesc\": \"支持群晖、QNAP、Nextcloud 等 WebDAV 服务\",\n        \"username\": \"用户名\",\n        \"usernamePlaceholder\": \"请输入用户名\",\n        \"password\": \"密码\",\n        \"passwordPlaceholder\": \"请输入密码\",\n        \"pathPrefix\": \"路径前缀\",\n        \"pathPrefixPlaceholder\": \"请输入路径前缀\",\n        \"pathPrefixDesc\": \"用于区分不同用户的文件\",\n        \"testConnection\": \"测试连接\",\n        \"testing\": \"测试中\",\n        \"saveConfig\": \"保存配置\",\n        \"saving\": \"保存中\"\n      }\n    },\n    \"imageHosting\": {\n      \"title\": \"圖床設置\",\n      \"desc\": \"在這裡，你可以配置圖床服務，用於儲存和管理你的圖片。\",\n      \"type\": \"選擇平台\",\n      \"typeDesc\": \"選擇圖床服務提供商\",\n      \"customRepoName\": \"自訂倉庫名\",\n      \"customRepoNameDesc\": \"留空則使用默認倉庫名\",\n      \"isPrimaryBackup\": \"當前 {type} 為主要圖床\",\n      \"setPrimaryBackup\": \"設為主要圖床\",\n      \"smms\": {\n        \"token\": {\n          \"desc\": \"請創建並輸入 SM.MS Token。\",\n          \"createToken\": \"創建 Token\"\n        },\n        \"disk\": \"磁碟使用情況\",\n        \"error\": \"獲取失敗，請檢查網路或 Token 是否正確。\"\n      },\n      \"picgo\": {\n        \"desc\": \"PicGo 伺服器地址\",\n        \"ok\": \"檢測到服務正在運行，請確保 PicGo 圖床已配置。\",\n        \"error\": \"服務未運行，請確保 PicGo（需要 v2.2.0+） 應用正在運行，否則無法上傳圖片。\"\n      },\n      \"github\": {\n        \"title\": \"GitHub 圖床\",\n        \"description\": \"使用 GitHub 倉庫作為圖片儲存服務\",\n        \"repoStatus\": \"倉庫狀態\",\n        \"repoExists\": \"倉庫已存在\",\n        \"repoNotExists\": \"倉庫不存在\",\n        \"checking\": \"檢測中\",\n        \"creating\": \"創建中\",\n        \"manualCreateTitle\": \"需要手動創建圖床倉庫\",\n        \"manualCreateDesc\": \"請按以下步驟創建圖床倉庫：\",\n        \"createSteps\": {\n          \"step1\": \"訪問 GitHub 並登入您的帳戶\",\n          \"step2\": \"點擊右上角的 \\\"+\\\" 按鈕，選擇 \\\"New repository\\\"\",\n          \"step3\": \"倉庫名稱設置為：\",\n          \"step4\": \"可以選擇設置為私有倉庫（推薦）\",\n          \"step5\": \"點擊 \\\"Create repository\\\" 完成創建\",\n          \"step6\": \"創建完成後，點擊下方的\\\"重新檢測\\\"按鈕\"\n        },\n        \"createNewRepo\": \"創建新倉庫\",\n        \"recheckRepo\": \"重新檢測\",\n        \"recheckingRepo\": \"檢測中...\"\n      },\n      \"s3\": {\n        \"title\": \"S3 對象儲存\",\n        \"description\": \"配置 AWS S3 或相容 S3 協議的對象儲存服務作為圖床\",\n        \"status\": \"連接狀態\",\n        \"connected\": \"已連接\",\n        \"connecting\": \"連線中\",\n        \"disconnected\": \"未連接\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"輸入 Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"輸入 Secret Access Key\",\n        \"region\": \"區域\",\n        \"bucket\": \"儲存桶\",\n        \"bucketPlaceholder\": \"輸入儲存桶名稱\",\n        \"advancedSettings\": \"進階設定\",\n        \"endpoint\": \"自訂端點\",\n        \"endpointDesc\": \"留空使用 AWS S3，或輸入相容 S3 的服務端點\",\n        \"customDomain\": \"自訂域名\",\n        \"customDomainDesc\": \"可選，用於訪問圖片的自訂域名\",\n        \"pathPrefix\": \"路徑前綴\",\n        \"pathPrefixDesc\": \"可選，圖片儲存的路徑前綴\",\n        \"save\": \"保存配置\",\n        \"test\": \"測試連接\",\n        \"setAsPrimary\": \"設為主要圖床\",\n        \"error\": \"配置錯誤\",\n        \"requiredFields\": \"請填寫必填欄位：Access Key ID、Secret Access Key、區域和儲存桶\",\n        \"saveSuccess\": \"配置保存成功\",\n        \"saveSuccessDesc\": \"S3 配置已保存\",\n        \"saveError\": \"配置保存失敗\",\n        \"testSuccess\": \"連接測試成功\",\n        \"testSuccessDesc\": \"S3 連接正常，可以上傳圖片\",\n        \"testFailed\": \"連接測試失敗\",\n        \"testFailedDesc\": \"請檢查配置資訊和網路連接\",\n        \"testFirstDesc\": \"請先測試連接成功後再設為主要圖床\",\n        \"setPrimarySuccess\": \"設置成功\",\n        \"setPrimarySuccessDesc\": \"S3 已設為主要圖床\"\n      }\n    },\n    \"backupSync\": {\n      \"title\": \"備用方案\",\n      \"desc\": \"在這裡，你可以使用其他方案來備份你的數據，你可以定期進行備份，以確保數據的安全。\",\n      \"localBackup\": {\n        \"tabTitle\": \"本地備份\",\n        \"export\": {\n          \"title\": \"匯出備份\",\n          \"desc\": \"將應用數據打包為 .zip 文件，保存到指定位置。\",\n          \"button\": \"選擇位置並匯出\",\n          \"simpleButton\": \"匯出\",\n          \"exporting\": \"匯出中...\"\n        },\n        \"import\": {\n          \"title\": \"匯入備份\",\n          \"desc\": \"從 .zip 文件恢復應用數據，將覆蓋當前所有數據。\",\n          \"button\": \"選擇文件並匯入\",\n          \"importing\": \"匯入中...\",\n          \"warning\": \"匯入操作將覆蓋所有當前數據，請確保已備份重要內容！\"\n        },\n        \"exportDialog\": {\n          \"title\": \"選擇備份文件保存位置\"\n        },\n        \"importDialog\": {\n          \"title\": \"選擇要匯入的備份文件\"\n        },\n        \"exportSuccess\": \"備份匯出成功！\",\n        \"exportError\": \"備份匯出失敗\",\n        \"importSuccess\": \"備份匯入成功！應用將重啟以應用更改。\",\n        \"importError\": \"備份匯入失敗\",\n        \"restartConfirm\": \"匯入完成！是否立即重啟應用以應用更改？\"\n      }\n    },\n    \"template\": {\n      \"title\": \"整理模板\",\n      \"desc\": \"在這裡，你可以創建和管理自訂整理模板，幫助 AI 按照你的需求整理記錄內容。\",\n      \"customTemplate\": \"自訂模板\",\n      \"addTemplate\": \"新增模板\",\n      \"deleteConfirm\": \"確認刪除模板嗎?\",\n      \"status\": \"狀態\",\n      \"name\": \"名稱\",\n      \"content\": \"內容\",\n      \"scope\": \"範圍\",\n      \"selectScope\": \"選擇範圍\",\n      \"addTemplateDesc\": \"請輸入自訂模板名稱和內容，幫助 AI 更好地理解你的需求。\",\n      \"editTemplate\": \"編輯自訂模板\",\n      \"noContent\": \"暫無內容\",\n      \"range\": {\n        \"all\": \"全部\",\n        \"today\": \"今天\",\n        \"week\": \"近一週\",\n        \"month\": \"近一月\",\n        \"threeMonth\": \"近三個月\",\n        \"year\": \"近一年\"\n      }\n    },\n    \"shortcut\": {\n      \"title\": \"快捷鍵\",\n      \"screenshot\": \"截圖記錄\",\n      \"link\": \"連結記錄\",\n      \"textRecord\": \"文本記錄\",\n      \"windowPin\": \"窗口置頂\"\n    },\n    \"theme\": {\n      \"title\": \"外觀\",\n      \"appTheme\": \"應用配色\",\n      \"previewTheme\": \"預覽內容主題\",\n      \"codeTheme\": \"代碼塊高亮主題\",\n      \"selectTheme\": \"選擇主題\"\n    },\n    \"chat\": {\n      \"title\": \"對話設置\",\n      \"desc\": \"在這裡，你可以配置對話相關的設置，包括摘要生成等功能。\",\n      \"primaryModel\": {\n        \"title\": \"主要模型\",\n        \"model\": {\n          \"title\": \"主要聊天模型\",\n          \"desc\": \"選擇用於日常對話的主要 AI 模型\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"工具欄設置\",\n        \"chatToolbar\": {\n          \"title\": \"對話工具欄\",\n          \"desc\": \"自定義對話工具欄按鈕的顯示順序和可見性\",\n          \"button\": \"設置\",\n          \"modelSelect\": {\n            \"desc\": \"切換用於對話的 AI 模型\"\n          },\n          \"promptSelect\": {\n            \"desc\": \"選擇對話使用的預設提示詞\"\n          },\n          \"chatLanguage\": {\n            \"desc\": \"設置對話的語言\"\n          },\n          \"chatLink\": {\n            \"title\": \"關聯標籤\",\n            \"desc\": \"關聯當前標籤的筆記內容到對話上下文\"\n          },\n          \"fileLink\": {\n            \"desc\": \"關聯文件或文件夾到對話上下文\"\n          },\n          \"mcpButton\": {\n            \"desc\": \"選擇並連接 MCP 伺服器以使用外部工具\"\n          },\n          \"ragSwitch\": {\n            \"title\": \"知識庫檢索\",\n            \"desc\": \"啟用向量知識庫檢索功能\"\n          },\n          \"clipboardMonitor\": {\n            \"title\": \"剪貼板監聽\",\n            \"desc\": \"自動監聽剪貼板內容變化\"\n          },\n          \"newChat\": {\n            \"desc\": \"開始新對話\"\n          },\n          \"clearContext\": {\n            \"desc\": \"清除對話上下文，保留聊天記錄\"\n          },\n          \"clearChat\": {\n            \"desc\": \"刪除所有聊天記錄\"\n          }\n        }\n      },\n      \"condense\": {\n        \"title\": \"對話摘要\",\n        \"enable\": {\n          \"title\": \"啟用摘要\",\n          \"desc\": \"自動壓縮長對話以節省 token 使用量\"\n        },\n        \"model\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"選擇用於生成摘要的 AI 模型\",\n          \"placeholder\": \"使用主模型\"\n        },\n        \"threshold\": {\n          \"title\": \"觸發閾值\",\n          \"desc\": \"AI 消息超過此數量時檢查壓縮\"\n        },\n        \"minToken\": {\n          \"title\": \"最小 Token 數\",\n          \"desc\": \"單條消息超過此 token 數才進行壓縮\"\n        },\n        \"keepLatest\": {\n          \"title\": \"保留最新條數\",\n          \"desc\": \"保留最新的 N 條 AI 消息不進行壓縮\"\n        },\n        \"maxLength\": {\n          \"title\": \"摘要長度限制\",\n          \"desc\": \"控制生成摘要的最大字數\"\n        },\n        \"prompt\": {\n          \"title\": \"自定義摘要提示詞\",\n          \"desc\": \"自定義生成摘要時使用的提示詞模板\",\n          \"label\": \"提示詞模板\",\n          \"placeholder\": \"輸入自定義提示詞...\",\n          \"help\": \"使用 {content} 作為原始內容的佔位符\",\n          \"save\": \"保存\",\n          \"reset\": \"重置為默認\"\n        }\n      },\n      \"inspiration\": {\n        \"title\": \"靈感模型\",\n        \"model\": {\n          \"title\": \"靈感生成模型\",\n          \"desc\": \"用於生成快速提示詞建議，幫助用戶快速開始對話\"\n        }\n      },\n      \"conversationTitle\": {\n        \"title\": \"会话标题\",\n        \"model\": {\n          \"title\": \"标题生成模型\",\n          \"desc\": \"选择用于生成会话标题的 AI 模型\"\n        }\n      }\n    },\n    \"dev\": {\n      \"title\": \"開發者\",\n      \"desc\": \"在這裡，你可以配置開發者選項，包括網路代理、數據清理和設定檔管理等進階功能。\",\n      \"clearData\": \"清理數據\",\n      \"clearDataConfirm\": \"確定清理數據嗎？\",\n      \"proxy\": \"代理，用於解決網路問題，配置後建議重啟應用。\",\n      \"proxyPlaceholder\": \"請輸入代理地址\",\n      \"proxyTitle\": \"網路代理\",\n      \"clearDataTitle\": \"清理數據\",\n      \"clearDataDesc\": \"清理數據資訊，包括系統配置資訊、資料庫（包含記錄）。\",\n      \"clearFileTitle\": \"清理文件\",\n      \"clearFileDesc\": \"清理文件，包括圖片、文章。\",\n      \"clearButton\": \"清理\",\n      \"configFileTitle\": \"設定檔管理\",\n      \"configFileDesc\": \"設定檔匯入與匯出，匯入設定檔將覆蓋當前配置，並且重啟後生效。\",\n      \"importConfigTitle\": \"匯入設定檔\",\n      \"exportConfigTitle\": \"匯出設定檔\",\n      \"importConfigSuccessMobile\": \"配置下載成功，請手動重啟應用\",\n      \"exportConfigSuccess\": \"匯出成功\",\n      \"importButton\": \"匯入\",\n      \"exportButton\": \"匯出\"\n    },\n    \"ai\": {\n      \"title\": \"模型配置\",\n      \"desc\": \"在這裡，你可以添加和管理各種自訂模型提供服務，配置後將解鎖 AI 相關功能，例如整理和對話功能。\",\n      \"modelTitle\": \"自訂名稱\",\n      \"modelConfigTitle\": \"模型配置\",\n      \"modelConfigDesc\": \"每一個配置對應一個 AI 模型，你可以通過模板或自訂創建新的配置。\",\n      \"providerInfo\": \"供應商資訊\",\n      \"providerInfoDesc\": \"此配置基於供應商模板創建，名稱和地址已預設，無需修改。\",\n      \"create\": \"創建新配置\",\n      \"createDesc\": \"選擇空配置或使用供應商模板創建新的配置。\",\n      \"createSection\": {\n        \"title\": \"自訂模型配置\",\n        \"descWithoutModels\": \"添加自訂 AI 模型配置來使用更強大的模型服務。\"\n      },\n      \"config\": \"配置單\",\n      \"custom\": \"自訂模型配置\",\n      \"addCustomModel\": \"自訂\",\n      \"deleteCustomModel\": \"刪除\",\n      \"deleteCustomModelConfirm\": \"確認刪除此自訂模型配置嗎?\",\n      \"copyConfig\": \"複製\",\n      \"builtin\": \"內建\",\n      \"modelSupport\": \"僅支持 openai 協議的 AI 模型\",\n      \"apiKeyUrl\": \"創建 API Key\",\n      \"modelType\": {\n        \"title\": \"模型類型\",\n        \"desc\": \"根據 AI 模型能力選擇模型類型，用以調用不同的介面。\",\n        \"chat\": \"對話\",\n        \"image\": \"生圖\",\n        \"video\": \"影片\",\n        \"audio\": \"語音\",\n        \"embedding\": \"嵌入\",\n        \"rerank\": \"重排序\",\n        \"tts\": \"文本转语音\",\n        \"stt\": \"语音转文本\"\n      },\n      \"modelList\": {\n        \"error\": {\n          \"title\": \"獲取模型列表失敗\",\n          \"description\": \"請檢查 API Key 或網路是否正確\"\n        }\n      },\n      \"selectModel\": \"請選擇模型\",\n      \"modelProviderDesc\": \"自訂模型僅支持 openai 協議的 AI 模型。\",\n      \"modelTitleDesc\": \"自訂名稱，用於標識 AI 模型，請勿重複。\",\n      \"modelBaseUrlDesc\": \"你只需要配置到版本號即可，例如：https://api.openai.com/v1，後綴會自動添加。\",\n      \"modelDesc\": \"部分模型支持獲取模型列表，如果不支持請手動配置。\",\n      \"temperatureDesc\": \"使用什麼採樣溫度，介於 0 和 2 之間。較高的值（如 0.8）將使輸出更加隨機，而較低的值（如 0.2）將使輸出更加集中和確定。 我們通常建議改變這個或top_p但不是兩者。\",\n      \"topPDesc\": \"一種替代溫度採樣的方法，稱為核採樣，其中模型考慮具有 top_p 機率質量的標記的結果。所以 0.1 意味著只考慮構成前 10% 機率質量的標記。 我們通常建議改變這個或temperature但不是兩者。\",\n      \"customHeaders\": \"自訂請求頭\",\n      \"customHeadersDesc\": \"添加自訂 HTTP 請求頭，支持多個鍵值對配置。\",\n      \"headerKey\": \"鍵\",\n      \"headerValue\": \"值\",\n      \"addHeader\": \"添加 Header\",\n      \"connectionSuccess\": \"AI 連接測試通過\",\n      \"connectionFailed\": \"連接失敗\",\n      \"voice\": \"語音類型\",\n      \"voiceDesc\": \"指定音訊模型使用的語音類型，如 'alloy'、'echo'、'fable' 等。\",\n      \"voicePlaceholder\": \"請輸入語音類型，如：alloy\",\n      \"enableStream\": \"流式響應\",\n      \"enableStreamDesc\": \"啟用流式響應可以即時顯示生成內容，但某些模型可能不支持此功能。\",\n      \"selectConfig\": \"請選擇配置\",\n      \"models\": \"模型列表\",\n      \"modelsDesc\": \"在這裡管理當前配置下的所有模型，每個模型可以有不同的類型和參數。\",\n      \"addModel\": \"添加模型\",\n      \"newModel\": \"新模型\",\n      \"checkConnection\": \"檢測連接\",\n      \"model\": \"模型\",\n      \"defaultModels\": {\n        \"title\": \"默認免費模型\",\n        \"desc\": \"NoteGen 為用戶提供免費的 AI 模型服務，由矽基流動提供支持，無需配置即可使用基礎功能。\",\n        \"chatModel\": {\n          \"name\": \"Qwen/Qwen3-8B\",\n          \"type\": \"對話模型\",\n          \"desc\": \"適用於日常對話、文本生成等場景\"\n        },\n        \"embeddingModel\": {\n          \"name\": \"BAAI/bge-m3\",\n          \"type\": \"嵌入模型\",\n          \"desc\": \"用於文本向量化、語義搜索等功能\"\n        },\n        \"visionModel\": {\n          \"name\": \"OpenGVLab/InternVL2-8B\",\n          \"type\": \"視覺模型\",\n          \"desc\": \"支持圖像理解、視覺問答等功能\"\n        },\n        \"completionModel\": {\n          \"name\": \"快速補全\",\n          \"type\": \"補全模型\",\n          \"desc\": \"用於 Markdown 編輯器的 AI 內聯補全，類似 GitHub Copilot，快速生成續寫內容\"\n        },\n        \"poweredBy\": \"由 SiliconFlow 提供技術支援\"\n      }\n    },\n    \"imageMethod\": {\n      \"title\": \"圖像識別\",\n      \"desc\": \"在這裡，你可以配置圖像識別相關設置，支持 OCR 和 VLM 兩種方式。\",\n      \"setPrimary\": \"設置為默認\",\n      \"isPrimary\": \"{type} 已設置為默認\",\n      \"ocr\": {\n        \"title\": \"OCR\",\n        \"languagePacks\": \"語言包\",\n        \"checkModels\": \"在此查詢全部模型\",\n        \"modelInstruction\": \"以逗號分隔，例如：eng,chi_sim\"\n      },\n      \"vlm\": {\n        \"title\": \"視覺語言模型\",\n        \"desc\": \"通過視覺語言模型識別圖片內容。\"\n      },\n      \"enable\": {\n        \"title\": \"启用图像识别\",\n        \"desc\": \"开启后，在截图记录和插图记录时会自动进行图片识别。关闭后将跳过图片识别步骤。\"\n      }\n    },\n    \"file\": {\n      \"title\": \"文件管理\",\n      \"desc\": \"在這裡，你可以管理工作區設置和其他文件相關選項。\",\n      \"workspace\": {\n        \"title\": \"工作區設置\",\n        \"desc\": \"設置應用程式的工作區目錄，文件將保存在該目錄中。\",\n        \"current\": \"工作區路徑\",\n        \"defaultPath\": \"默認工作區\",\n        \"default\": \"當前使用默認工作區路徑。\",\n        \"custom\": \"當前使用自訂工作區路徑。\",\n        \"select\": \"選擇工作區目錄\",\n        \"reset\": \"重設為默認路徑\",\n        \"history\": \"歷史路徑\",\n        \"selectFromHistory\": \"從歷史記錄中選擇工作區\",\n        \"clearHistory\": \"清空歷史記錄\",\n        \"actions\": \"操作\",\n        \"searchPlaceholder\": \"搜索工作區路徑...\",\n        \"noResults\": \"未找到結果\"\n      },\n      \"info\": {\n        \"title\": \"工作區說明\",\n        \"desc\": \"更改工作區後需要重啟應用程式才能完全生效。新工作區中的文件將在重啟後顯示。\"\n      },\n      \"toast\": {\n        \"updated\": \"工作區已更新\",\n        \"updatedDesc\": \"工作區已設置為: {path}\",\n        \"reset\": \"工作區已重設\",\n        \"resetDesc\": \"已恢復使用默認工作區\",\n        \"error\": \"選擇工作區失敗\",\n        \"errorDesc\": \"無法選擇工作區目錄，請重試\",\n        \"resetError\": \"重設工作區失敗\",\n        \"resetErrorDesc\": \"無法重設為默認工作區，請重試\"\n      },\n      \"assets\": {\n        \"title\": \"寫作資源路徑\",\n        \"desc\": \"設置寫作資源的保存路徑，例如圖片、影片、文件等，與當前編輯的 markdown 文件同級。\",\n        \"select\": \"請設置寫作資源路徑，例如：assets\"\n      }\n    },\n    \"shortcuts\": {\n      \"title\": \"快捷鍵\",\n      \"desc\": \"在這裡，你可以配置快捷鍵，幫助你更高效地使用 NoteGen。\",\n      \"resetDefaults\": \"重設\",\n      \"clear\": \"清空\",\n      \"noShortcut\": \"未設置\",\n      \"shortcuts\": {\n        \"openWindow\": {\n          \"title\": \"打開/隱藏窗口\",\n          \"desc\": \"打開/隱藏程序主窗口。\"\n        },\n        \"quickRecordText\": {\n          \"title\": \"快速記錄文本\",\n          \"desc\": \"快速打開程式主窗口，並調出文本記錄。\"\n        }\n      }\n    },\n    \"skills\": {\n      \"title\": \"Skills\",\n      \"desc\": \"Skills 是可重用的 AI 能力包，让 AI 助手能够根据任务自动应用特定的行为模式。\",\n      \"enable\": \"启用 Skills 功能\",\n      \"enableDesc\": \"启用后，AI 可以使用已配置的 Skills\",\n      \"autoMatch\": \"自动匹配 Skills\",\n      \"autoMatchDesc\": \"根据用户输入自动选择合适的 Skills\",\n      \"project\": \"工作区 Skills\",\n      \"global\": \"全局 Skills\",\n      \"globalPath\": \"全局 Skills 存储位置\",\n      \"openInFileManager\": \"在文件管理器中打开\",\n      \"createSkill\": \"创建 Skill\",\n      \"editSkill\": \"编辑 Skill\",\n      \"deleteSkill\": \"删除 Skill\",\n      \"exportSkill\": \"导出 Skill\",\n      \"importSkill\": \"导入 Skill\",\n      \"selectSkillZip\": \"选择 Skill zip 文件\",\n      \"importSuccess\": \"导入成功\",\n      \"importError\": \"导入失败\",\n      \"imported\": \"已导入\",\n      \"importing\": \"导入中...\",\n      \"skillName\": \"Skill 名称\",\n      \"skillDescription\": \"描述\",\n      \"skillVersion\": \"版本\",\n      \"skillAuthor\": \"作者\",\n      \"allowedTools\": \"允许使用的工具\",\n      \"userInvocable\": \"在斜杠菜单显示\",\n      \"instructions\": \"指令内容\",\n      \"instructionsPlaceholder\": \"输入给 AI 的详细指令...\",\n      \"importHelp\": \"支持导入 zip 格式的 Skill，zip 文件需包含 SKILL.md 文件。\",\n      \"metadata\": \"元数据\",\n      \"content\": \"指令内容\",\n      \"noSkills\": \"还没有 Skills\",\n      \"noSkillsDesc\": \"创建或导入 Skills 以开始使用\",\n      \"noSkillsGlobal\": \"还没有全局 Skills\",\n      \"noSkillsGlobalDesc\": \"创建或导入 Skills 以在所有项目中使用\",\n      \"emptyWorkspace\": \"工作区中没有 Skills\",\n      \"emptyWorkspaceDesc\": \"在 skills 文件夹中创建 SKILL.md 文件来添加 Skill\",\n      \"basicSettings\": \"基础设置\",\n      \"installedGlobalSkills\": \"已安装的全局 Skills\",\n      \"nameRequired\": \"请输入 Skill 名称\",\n      \"descriptionRequired\": \"请输入描述\",\n      \"namePlaceholder\": \"note-organizer\",\n      \"versionPlaceholder\": \"1.0.0\",\n      \"descriptionPlaceholder\": \"自动整理和优化笔记结构...\",\n      \"authorPlaceholder\": \"Your Name\",\n      \"descriptionHelp\": \"用于 AI 匹配，描述此 Skill 的功能和适用场景\",\n      \"allowedToolsHelp\": \"这些工具使用时不需要用户确认\",\n      \"userInvocableHelp\": \"用户可以通过 /skill-name 手动触发\",\n      \"instructionsHelp\": \"给 AI 的详细指令，支持 Markdown 格式\",\n      \"deleteSkillTitle\": \"删除 Skill\",\n      \"deleteSkillDesc\": \"确定要删除这个 Skill 吗？此操作无法撤销。\",\n      \"skillDeleted\": \"Skill 删除成功\"\n    },\n    \"audio\": {\n      \"title\": \"语音设置\",\n      \"desc\": \"在这里，你可以配置语音相关设置，包括文本转语音（朗读）和语音转文本（录音识别）功能。\",\n      \"mode\": {\n        \"title\": \"運行方式\",\n        \"auto\": \"自動（推薦）\",\n        \"local\": \"僅本地\",\n        \"model\": \"僅模型\"\n      },\n      \"tts\": {\n        \"title\": \"文本转语音（TTS）\",\n        \"desc\": \"配置朗读功能，为聊天内容提供语音播放。\",\n        \"modeDesc\": \"預設優先使用瀏覽器與系統語音，需要更佳效果時再使用模型。\",\n        \"model\": {\n          \"title\": \"朗读模型\",\n          \"desc\": \"可選。配置後可在自動模式下增強體驗，或在僅模型模式下使用。\"\n        },\n        \"speed\": {\n          \"title\": \"语速\",\n          \"desc\": \"调整语音播放的速度，范围从0.5倍到2倍速度，默认为1倍正常速度。\"\n        }\n      },\n      \"stt\": {\n        \"title\": \"语音转文本（STT）\",\n        \"desc\": \"配置录音识别功能，将语音转换为文字记录。\",\n        \"modeDesc\": \"預設優先使用瀏覽器原生識別，不支援時再回退到模型識別。\",\n        \"model\": {\n          \"title\": \"识别模型\",\n          \"desc\": \"可選。配置後可在自動模式下回退使用，或在僅模型模式下強制使用。\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"朗讀\",\n      \"desc\": \"配置文字朗讀方式，預設優先使用系統語音，模型作為增強能力。\",\n      \"options\": {\n        \"mode\": {\n          \"title\": \"運行方式\",\n          \"desc\": \"自動模式會優先使用系統語音，不可用時再嘗試模型。\",\n          \"auto\": \"自動（推薦）\",\n          \"local\": \"僅本地\",\n          \"model\": \"僅模型\"\n        },\n        \"audioModel\": {\n          \"title\": \"朗讀模型\",\n          \"desc\": \"可選。配置後可在自動模式下增強體驗，或在僅模型模式下使用。\"\n        },\n        \"speed\": {\n          \"title\": \"語速\",\n          \"desc\": \"調整朗讀速度，範圍從0.5倍到2倍，預設為1倍。\"\n        }\n      }\n    }\n  },\n  \"record\": {\n    \"trash\": {\n      \"title\": \"清空回收站\",\n      \"confirm\": \"確定清空回收站嗎？\",\n      \"records\": \"共 {count} 條紀錄可還原\",\n      \"empty\": \"清空\",\n      \"close\": \"關閉回收站\"\n    },\n    \"queue\": {\n      \"ocr\": \"OCR 識別\",\n      \"ai\": \"AI 內容識別\",\n      \"upload\": \"上傳至圖床\",\n      \"jsdelivr\": \"通知 jsdelivr 快取\",\n      \"recording\": \"記錄中...\",\n      \"recorded\": \"已記錄\",\n      \"record\": \"記錄\",\n      \"detected\": \"檢測到\",\n      \"save\": \"保存\"\n    },\n    \"mark\": {\n      \"empty\": \"暫無記錄\",\n      \"loading\": \"載入中...\",\n      \"type\": {\n        \"scan\": \"截圖\",\n        \"image\": \"插圖\",\n        \"screenshot\": \"截圖\",\n        \"text\": \"文本\",\n        \"file\": \"文件\",\n        \"link\": \"連結\",\n        \"todo\": \"待辦\",\n        \"pdf\": \"PDF\",\n        \"upload\": \"上傳記錄\",\n        \"download\": \"下載記錄\",\n        \"uploadTo\": \"從本地同步到 {provider}\",\n        \"downloadFrom\": \"從 {provider} 同步到本地\",\n        \"recording\": \"录音\"\n      },\n      \"note\": {\n        \"organizeAs\": \"整理為\",\n        \"template\": \"模板\",\n        \"setting\": \"設置\",\n        \"confirm\": \"確認\",\n        \"cancel\": \"取消\",\n        \"removeThinking\": \"移除思考過程\",\n        \"stop\": \"停止\"\n      },\n      \"uploadSuccess\": \"記錄上傳成功\",\n      \"downloadSuccess\": \"記錄下載成功\",\n      \"desc\": \"描述\",\n      \"content\": \"內容\",\n      \"createdAt\": \"創建於\",\n      \"progress\": {\n        \"cacheImage\": \"快取圖片\",\n        \"ocr\": \"OCR 識別\",\n        \"aiAnalysis\": \"AI 內容識別\",\n        \"uploadImage\": \"上傳至圖床\",\n        \"jsdelivrCache\": \"通知 jsdelivr 快取\",\n        \"cacheFile\": \"快取文件\",\n        \"cacheScreenshot\": \"快取截圖\",\n        \"textAnalysis\": \"文本分析\",\n        \"save\": \"保存\",\n        \"saveImage\": \"保存圖片\"\n      },\n      \"text\": {\n        \"title\": \"記錄文本\",\n        \"description\": \"記錄一段文本，筆記整理時將插入到合適的位置。\",\n        \"characterCount\": \"{count} 字元\",\n        \"save\": \"記錄\",\n        \"autoReadClipboard\": \"自動讀取剪貼簿文本\"\n      },\n      \"link\": {\n        \"title\": \"連結記錄\",\n        \"description\": \"輸入網頁連結，系統將自動爬取頁面內容並保存\",\n        \"save\": \"保存\",\n        \"autoReadClipboard\": \"自動讀取剪貼簿連結\"\n      },\n      \"todo\": {\n        \"title\": \"待辦記錄\",\n        \"description\": \"創建待辦事項，管理你的任務\",\n        \"titlePlaceholder\": \"輸入待辦標題...\",\n        \"descriptionPlaceholder\": \"輸入詳細描述（可選）\",\n        \"priority\": \"優先級\",\n        \"priorityLow\": \"低\",\n        \"priorityMedium\": \"中\",\n        \"priorityHigh\": \"高\",\n        \"dateRange\": \"日期範圍\",\n        \"dateRangePlaceholder\": \"選擇日期範圍\",\n        \"dueDate\": \"截止日期\",\n        \"dueDatePlaceholder\": \"選擇日期\",\n        \"save\": \"創建待辦\",\n        \"saveEdit\": \"保存\",\n        \"edit\": \"編輯待辦\",\n        \"editDescription\": \"修改待辦事項的詳細信息\",\n        \"cancel\": \"取消\",\n        \"selectTag\": \"選擇標籤\",\n        \"completed\": \"已完成\",\n        \"uncompleted\": \"未完成\"\n      },\n      \"clipboard\": {\n        \"detectedImage\": \"檢測到剪貼簿圖片\",\n        \"detectedText\": \"檢測到剪貼簿文本\"\n      },\n      \"tag\": {\n        \"searchPlaceholder\": \"創建或查詢標籤...\",\n        \"noResults\": \"未查詢到相關標籤\",\n        \"quickAdd\": \"快速創建\",\n        \"pinned\": \"置頂\",\n        \"others\": \"其他\",\n        \"rename\": \"重命名\",\n        \"delete\": \"刪除\",\n        \"pin\": \"置頂\",\n        \"unpin\": \"取消置頂\",\n        \"newTag\": \"新建標籤\",\n        \"newTagPlaceholder\": \"輸入標籤名稱...\",\n        \"add\": \"添加\"\n      },\n      \"mark\": {\n        \"empty\": \"暫無記錄\",\n        \"emptyHint\": \"使用頂部工具欄開始你的第一條紀錄吧！\",\n        \"type\": {\n          \"text\": \"文本\"\n        },\n        \"chat\": {\n          \"modeSelect\": {\n            \"chat\": \"對話\",\n            \"agent\": \"智能體\"\n          },\n          \"agent\": {\n            \"running\": \"Agent 運行中\",\n            \"thinking\": \"思考中\",\n            \"acting\": \"執行中\",\n            \"observation\": \"觀察結果\",\n            \"toolCalls\": \"工具調用\",\n            \"thought\": \"思考\",\n            \"action\": \"行動\",\n            \"confirmation\": {\n              \"title\": \"確認操作\",\n              \"description\": \"Agent 想要執行以下操作，請確認後繼續。\",\n              \"tool\": \"工具\",\n              \"parameters\": \"參數\",\n              \"cancel\": \"取消\",\n              \"confirm\": \"確認\",\n              \"confirmed\": \"已確認\",\n              \"cancelled\": \"已取消\"\n            }\n          },\n          \"placeholder\": {\n            \"default\": \"你可以提問或將記錄整理為文章...\",\n            \"noApiKey\": \"未配置 API Key，無法使用 AI 對話功能...\",\n            \"on\": \"AI建議開啟\",\n            \"off\": \"AI建議關閉\"\n          },\n          \"header\": {\n            \"configApiKey\": \"配置 API KEY\",\n            \"clearChat\": \"清空對話\",\n            \"configPrompt\": \"配置 Prompt\",\n            \"selectPrompt\": \"選擇 Prompt\"\n          },\n          \"clipboard\": {\n            \"image\": {\n              \"detected\": \"檢測到剪貼簿存在圖片：\",\n              \"recording\": \"正在記錄\",\n              \"recorded\": \"已記錄\",\n              \"record\": \"記錄\"\n            },\n            \"text\": {\n              \"detected\": \"檢測到剪貼簿存在文本：\",\n              \"recorded\": \"已記錄\",\n              \"record\": \"記錄\"\n            }\n          },\n          \"messageControl\": {\n            \"words\": \"字\",\n            \"summary\": \"摘要\"\n          },\n          \"mcp\": {\n            \"maxIterationsReached\": \"達到最大工具調用次數限制\",\n            \"toolCall\": \"MCP 伺服器\",\n            \"params\": \"參數\",\n            \"result\": \"返回結果\",\n            \"copy\": \"複製\",\n            \"paramsCopied\": \"參數已複製\",\n            \"resultCopied\": \"結果已複製\",\n            \"calling\": \"調用中\",\n            \"success\": \"已完成\",\n            \"error\": \"失敗\"\n          },\n          \"empty\": {\n            \"title\": \"開始與 AI 對話\",\n            \"subtitle\": \"使用 Chat 或 Agent 模式與 AI 互動\",\n            \"currentModel\": \"當前模型\",\n            \"currentPrompt\": \"當前 Prompt\",\n            \"currentMode\": \"對話模式\",\n            \"noModel\": \"未設置模型\",\n            \"noPrompt\": \"未設置 Prompt\",\n            \"configureModel\": \"配置模型\",\n            \"recentConversations\": \"最近對話\",\n            \"deleteConversation\": \"刪除對話\",\n            \"conversationHistory\": \"歷史對話\",\n            \"viewMore\": \"查看更多\",\n            \"messages\": \"條消息\",\n            \"searchPlaceholder\": \"搜索對話...\",\n            \"noMatchingConversations\": \"沒有找到匹配的對話\",\n            \"noConversationHistory\": \"暫無歷史對話\",\n            \"quickPrompts\": {\n              \"title\": \"快速開始\",\n              \"writeNote\": \"幫我寫一篇筆記\",\n              \"summarize\": \"幫我總結這段內容\",\n              \"brainstorm\": \"幫我頭腦風暴一些想法\",\n              \"explain\": \"幫我解釋這個概念\"\n            },\n            \"modeHint\": \"點擊輸入框左側的\",\n            \"modeHintSuffix\": \"按鈕可切換對話模式\"\n          },\n          \"content\": {\n            \"organize\": \"將你的紀錄整理為文章：\"\n          },\n          \"note\": {\n            \"writing\": \"寫作\",\n            \"convert\": \"轉化文章\",\n            \"description\": \"當前的筆記是由 AI 生成且無法編輯，將當前筆記轉化為文章（生成本地文件），可在寫作頁面中進行二次創作。\",\n            \"filename\": \"檔案名\",\n            \"selectFolder\": \"選擇文件夾\",\n            \"rootDirectory\": \"根目錄\",\n            \"deleteTag\": \"刪除當前標籤、記錄和筆記（回收站可恢復）\",\n            \"warning\": \"轉換後將跳轉到寫作頁面。\",\n            \"convert_button\": \"轉化\"\n          },\n          \"mark\": {\n            \"recorded\": \"已記錄\",\n            \"record\": \"記錄\"\n          },\n          \"send\": \"發送\"\n        },\n        \"text\": {\n          \"title\": \"記錄文本\",\n          \"description\": \"記錄一段文本，筆記整理時將插入到合適的位置。\",\n          \"characterCount\": \"{count} 字元\",\n          \"save\": \"記錄\"\n        },\n        \"clipboard\": {\n          \"detectedImage\": \"檢測到剪貼簿圖片\",\n          \"detectedText\": \"檢測到剪貼簿文本\"\n        },\n        \"tag\": {\n          \"searchPlaceholder\": \"創建或查詢標籤...\",\n          \"noResults\": \"未查詢到相關標籤\",\n          \"quickAdd\": \"快速創建\",\n          \"pinned\": \"置頂\",\n          \"others\": \"其他\",\n          \"rename\": \"重命名\",\n          \"delete\": \"刪除\",\n          \"pin\": \"置頂\",\n          \"unpin\": \"取消置頂\"\n        },\n        \"progress\": {\n          \"cacheImage\": \"快取圖片\",\n          \"ocr\": \"OCR 識別\",\n          \"aiAnalysis\": \"AI 內容識別\",\n          \"uploadImage\": \"上傳至圖床\",\n          \"jsdelivrCache\": \"通知 jsdelivr 快取\",\n          \"cacheFile\": \"快取文件\",\n          \"cacheScreenshot\": \"快取截圖\",\n          \"textAnalysis\": \"文本分析\",\n          \"save\": \"保存\",\n          \"saveImage\": \"保存圖片\"\n        }\n      },\n      \"toolbar\": {\n        \"search\": \"搜索\",\n        \"trash\": \"回收站\",\n        \"restore\": \"還原\",\n        \"delete\": \"刪除\",\n        \"deleteConfirm\": \"確定要刪除嗎？\",\n        \"moveTag\": \"轉移標籤\",\n        \"convertTo\": \"轉換為{type}\",\n        \"copyLink\": \"複製連結\",\n        \"copied\": \"已複製到剪切板！\",\n        \"regenerateDesc\": \"重新生成描述\",\n        \"viewFolder\": \"查看目錄\",\n        \"viewFile\": \"查看原文件\",\n        \"deleteForever\": \"徹底刪除\",\n        \"multiSelect\": \"多選\",\n        \"exitMultiSelect\": \"退出多選\",\n        \"selectAll\": \"全選\",\n        \"deselectAll\": \"取消全選\",\n        \"selectedCount\": \"已選擇 {count} 項\",\n        \"moveSelectedTags\": \"轉移選中的 {count} 項\",\n        \"deleteSelected\": \"刪除選中的 {count} 項\",\n        \"deleteSelectedForever\": \"徹底刪除選中的 {count} 項\",\n        \"organizeNotes\": \"整理筆記\",\n        \"organizeSuccess\": \"筆記整理成功：{title}\",\n        \"organizeError\": \"整理筆記失敗\",\n        \"currentTag\": \"當前標籤\",\n        \"text\": \"記錄文本\",\n        \"recording\": \"錄音記錄\",\n        \"scan\": \"掃描圖片\",\n        \"image\": \"上傳圖片\",\n        \"link\": \"記錄連結\",\n        \"file\": \"上傳文件\",\n        \"todo\": \"待辦記錄\",\n        \"closeTrash\": \"關閉回收站\"\n      },\n      \"list\": {\n        \"title\": \"記錄\"\n      },\n      \"imageGallery\": {\n        \"expand\": \"展开\",\n        \"collapse\": \"收起\"\n      }\n    },\n    \"chat\": {\n      \"empty\": {\n        \"features\": [\n          {\n            \"chat\": \"與 AI 機器人進行聊天\"\n          },\n          {\n            \"linked\": \"已與你的紀錄或筆記關聯\"\n          },\n          {\n            \"clipboard\": \"識別剪貼簿中的文字和圖片\"\n          },\n          {\n            \"organize\": \"將你的紀錄整理為筆記\"\n          }\n        ],\n        \"title\": \"開始與 AI 對話\",\n        \"subtitle\": \"使用 Chat 或 Agent 模式與 AI 互動\",\n        \"currentModel\": \"當前模型\",\n        \"currentPrompt\": \"當前 Prompt\",\n        \"currentMode\": \"對話模式\",\n        \"noModel\": \"未設置模型\",\n        \"noPrompt\": \"未設置 Prompt\",\n        \"configureModel\": \"配置模型\",\n        \"recentConversations\": \"最近對話\",\n        \"deleteConversation\": \"刪除對話\",\n        \"conversationHistory\": \"歷史對話\",\n        \"viewMore\": \"查看更多\",\n        \"messages\": \"條消息\",\n        \"searchPlaceholder\": \"搜索對話...\",\n        \"noMatchingConversations\": \"沒有找到匹配的對話\",\n        \"noConversationHistory\": \"暫無歷史對話\",\n        \"quickPrompts\": {\n          \"title\": \"快速开始\",\n          \"writeNote\": \"帮我写一篇笔记\",\n          \"summarize\": \"帮我总结这段内容\",\n          \"brainstorm\": \"帮我头脑风暴一些想法\",\n          \"explain\": \"帮我解释这个概念\"\n        }\n      },\n      \"newChat\": \"使用新標籤開始對話\",\n      \"removeChat\": \"刪除當前標籤對話\",\n      \"confirmNew\": \"創建新標籤\",\n      \"confirmNewDescription\": \"請確認要創建一個新的標籤開始對話嗎？\",\n      \"confirmRemove\": \"刪除標籤\",\n      \"confirmRemoveDescription\": \"請注意，刪除此標籤會連帶刪除內部的紀錄，請再次確認。\",\n      \"input\": {\n        \"organize\": \"整理\",\n        \"chat\": \"聊天\",\n        \"placeholder\": {\n          \"default\": \"你可以提問或將記錄整理為文章...\",\n          \"noApiKey\": \"未配置 API Key，無法使用 AI 對話功能...\",\n          \"on\": \"AI 建議 (開啟)\",\n          \"off\": \"AI 建議 (關閉)\",\n          \"noPrimaryModel\": \"未配置主模型，無法使用 AI 對話功能...\"\n        },\n        \"translate\": {\n          \"tooltip\": \"翻譯\",\n          \"translating\": \"翻譯中...\",\n          \"showOriginal\": \"顯示原文\",\n          \"alreadyTranslated\": \"已翻譯為\"\n        },\n        \"clipboardMonitor\": {\n          \"enable\": \"剪貼簿監聽(開啟)\",\n          \"disable\": \"剪貼簿監聽(關閉)\"\n        },\n        \"send\": \"發送\",\n        \"stop\": \"停止\",\n        \"terminate\": \"終止\",\n        \"tagLink\": {\n          \"on\": \"已關聯標籤\",\n          \"off\": \"未關聯標籤\"\n        },\n        \"modelSelect\": {\n          \"tooltip\": \"選擇 AI 模型\",\n          \"placeholder\": \"搜索 AI 模型\",\n          \"noModel\": \"未找到模型\"\n        },\n        \"promptSelect\": {\n          \"tooltip\": \"選擇 Prompt\",\n          \"placeholder\": \"搜索 Prompt\"\n        },\n        \"clearChat\": \"清空對話\",\n        \"clearContext\": {\n          \"tooltip\": \"清除上下文\"\n        },\n        \"chatLanguage\": {\n          \"tooltip\": \"選擇對話語言\",\n          \"placeholder\": \"搜索語言\"\n        },\n        \"rag\": {\n          \"notSupported\": \"向量模型不可用\",\n          \"enabled\": \"知識庫檢索（開啟）\",\n          \"disabled\": \"知識庫檢索（關閉）\"\n        },\n        \"modeSelect\": {\n          \"tooltip\": \"選擇輸入模式\",\n          \"chat\": \"對話模式\",\n          \"gen\": \"整理模式\",\n          \"translate\": \"翻譯模式\"\n        },\n        \"chatModeSelect\": {\n          \"chatDescription\": \"快速對話，分析優先\",\n          \"agentDescription\": \"智能助手，可執行操作\"\n        },\n        \"attachImage\": \"附加圖片\",\n        \"imageSelector\": {\n          \"title\": \"選擇圖片\",\n          \"local\": \"本地文件\",\n          \"records\": \"從記錄中選擇\",\n          \"selectFiles\": \"選擇本地圖片\",\n          \"noRecords\": \"沒有可用的圖片記錄\",\n          \"cancel\": \"取消\",\n          \"confirm\": \"確認\"\n        },\n        \"agent\": {\n          \"running\": \"Agent 運行中\",\n          \"thinking\": \"思考中\",\n          \"analyzingRequest\": \"Agent 正在分析您的請求...\",\n          \"acting\": \"執行中\",\n          \"observation\": \"觀察結果\",\n          \"toolCalls\": \"工具調用\",\n          \"autoFinal\": {\n            \"createNote\": \"已建立筆記《{name}》。\",\n            \"createFile\": \"已建立檔案《{name}》。\"\n          },\n          \"thought\": \"思考\",\n          \"action\": \"行動\",\n          \"confirmation\": {\n            \"title\": \"確認操作\",\n            \"description\": \"Agent 想要執行以下操作，請確認後繼續。\",\n            \"tool\": \"工具\",\n            \"parameters\": \"參數\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"確認\",\n            \"confirmed\": \"已確認\",\n            \"cancelled\": \"已取消\"\n          }\n        },\n        \"fileLink\": {\n          \"tooltip\": \"關聯文件\",\n          \"selectFile\": \"選擇文件\",\n          \"linkedFile\": \"關聯文件\",\n          \"searchPlaceholder\": \"搜尋文件...\",\n          \"noFiles\": \"未找到文件\",\n          \"loading\": \"載入中...\"\n        },\n        \"stopped\": \"对话已终止\",\n        \"mcp\": {\n          \"tooltip\": \"MCP 服务器\"\n        },\n        \"newChat\": \"开始新对话\"\n      },\n      \"header\": {\n        \"configApiKey\": \"配置 API KEY\",\n        \"clearChat\": \"清空聊天\",\n        \"configPrompt\": \"配置 Prompt\",\n        \"selectPrompt\": \"選擇 Prompt\",\n        \"noModel\": \"未選擇 AI 模型\"\n      },\n      \"clipboard\": {\n        \"image\": {\n          \"detected\": \"檢測到剪貼簿存在圖片：\",\n          \"recording\": \"正在記錄\",\n          \"recorded\": \"已記錄\",\n          \"record\": \"記錄\"\n        },\n        \"text\": {\n          \"detected\": \"檢測到剪貼簿存在文本：\",\n          \"recorded\": \"已記錄\",\n          \"record\": \"記錄\"\n        }\n      },\n      \"messageControl\": {\n        \"words\": \"字\",\n        \"summary\": \"摘要\",\n        \"readAloud\": \"朗讀\",\n        \"playing\": \"播放中\",\n        \"loading\": \"準備中\",\n        \"stop\": \"停止播放\",\n        \"copy\": \"複製\",\n        \"copied\": \"已複製\"\n      },\n      \"ragSources\": {\n        \"label\": \"知識庫檢索到 {count} 篇筆記\",\n        \"openFile\": \"打開文件\"\n      },\n      \"preview\": {\n        \"close\": \"關閉\",\n        \"copy\": \"複製\",\n        \"copied\": \"已複製！\"\n      },\n      \"control\": {\n        \"edit\": \"編輯\",\n        \"save\": \"保存\",\n        \"cancel\": \"取消\",\n        \"delete\": \"刪除\",\n        \"deleteConfirm\": \"確定要刪除這條消息嗎？\"\n      },\n      \"content\": {\n        \"organize\": \"將你的紀錄整理為文章：\"\n      },\n      \"quote\": {\n        \"lineSingle\": \"引用自 {fileName} 第 {line} 行\",\n        \"lineRange\": \"引用自 {fileName} 第 {startLine}-{endLine} 行\",\n        \"noLine\": \"引用自 {fileName}\"\n      },\n      \"note\": {\n        \"organize\": \"整理\",\n        \"writing\": \"寫作\",\n        \"convert\": \"轉化文章\",\n        \"description\": \"當前的筆記是由 AI 生成且無法編輯，將當前筆記轉化為文章（生成本地文件），可在寫作頁面中進行二次創作。\",\n        \"filename\": \"檔案名\",\n        \"selectFolder\": \"選擇文件夾\",\n        \"rootDirectory\": \"根目錄\",\n        \"deleteTag\": \"刪除當前標籤、記錄和筆記（回收站可恢復）\",\n        \"warning\": \"轉換後將跳轉到寫作頁面。\",\n        \"convert_button\": \"轉化\",\n        \"organizeAs\": \"將記錄整理成...\",\n        \"templateContent\": \"模板內容\",\n        \"recordRange\": \"記錄選擇範圍\",\n        \"filterThinkingContent\": \"移除記錄中的思考\",\n        \"startOrganize\": \"開始整理\",\n        \"manageTemplate\": \"管理模板\",\n        \"cancel\": \"取消\",\n        \"stop\": \"停止\"\n      },\n      \"mark\": {\n        \"recorded\": \"已記錄\",\n        \"record\": \"記錄\"\n      },\n      \"condensing\": \"正在压缩上下文...\",\n      \"condensed\": {\n        \"message\": \"已压缩 {count} 条历史消息\"\n      }\n    },\n    \"tag\": {\n      \"add\": \"添加標籤\",\n      \"edit\": \"編輯標籤\",\n      \"delete\": \"刪除標籤\",\n      \"deleteConfirm\": \"確定要刪除這個標籤嗎？\",\n      \"placeholder\": \"輸入標籤名稱\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"搜尋筆記和文章...\",\n    \"results\": \"{count} 個搜索結果\",\n    \"noResults\": \"暫無搜索結果\",\n    \"tryDifferentKeywords\": \"嘗試使用不同的關鍵字搜索\",\n    \"mode\": {\n      \"fuzzy\": \"模糊\",\n      \"exact\": \"精確\"\n    },\n    \"item\": {\n      \"record\": \"記錄\",\n      \"article\": \"文章\",\n      \"matches\": \"{count}個匹配項\",\n      \"scanType\": \"截圖\"\n    }\n  },\n  \"image\": {\n    \"root\": \"圖床倉庫\",\n    \"noData\": {\n      \"title\": \"同步功能未開啟\",\n      \"desc\": \"請先跳轉至系統設置頁面，配置 Github 同步。\",\n      \"goToSettings\": \"前往設置\",\n      \"howToUse\": \"如何使用同步功能？\"\n    }\n  },\n  \"navigation\": {\n    \"chat\": \"對話\",\n    \"record\": \"記錄\",\n    \"quickRecord\": \"快捷記錄\",\n    \"write\": \"寫作\",\n    \"search\": \"搜索\",\n    \"githubImageHosting\": \"Github 圖床\",\n    \"login\": \"登錄\",\n    \"loading\": \"載入中\",\n    \"view\": \"查看\",\n    \"logout\": \"登出\",\n    \"setting\": \"設置\",\n    \"activity\": \"活躍度\",\n    \"files\": \"筆記\",\n    \"outline\": \"大綱\",\n    \"hideLeftSidebar\": \"隱藏左側邊欄\",\n    \"showLeftSidebar\": \"顯示左側邊欄\",\n    \"hideRightSidebar\": \"隱藏右側邊欄\",\n    \"showRightSidebar\": \"顯示右側邊欄\",\n    \"searchPlaceholder\": \"搜索筆記或記錄...\",\n    \"showCenterPanel\": \"显示编辑器\",\n    \"hideCenterPanel\": \"隐藏编辑器\"\n  },\n  \"activity\": {\n    \"title\": \"活躍度日曆\",\n    \"description\": \"按天查看你的記錄、對話與寫作活躍情況。第一版基於現有記錄、使用者對話與筆記修改時間進行統計。\",\n    \"drawer\": {\n      \"title\": \"活躍度\",\n      \"description\": \"快速查看今天狀態和最近一段時間的活躍趨勢。\",\n      \"today\": \"今天\"\n    },\n    \"loading\": \"正在載入活躍度資料...\",\n    \"empty\": \"暫無活躍度資料\",\n    \"refresh\": \"重新整理\",\n    \"summary\": {\n      \"totalCount\": \"總活躍次數\",\n      \"activeDays\": \"活躍天數\",\n      \"records\": \"記錄次數\",\n      \"chats\": \"對話次數\",\n      \"writing\": \"寫作活躍\"\n    },\n    \"labels\": {\n      \"record\": \"記錄\",\n      \"writing\": \"寫作\",\n      \"chat\": \"對話\"\n    },\n    \"heatmap\": {\n      \"title\": \"最近 26 週熱力圖\",\n      \"range\": \"{startDate} - {endDate}\",\n      \"less\": \"少\",\n      \"more\": \"多\",\n      \"dayCount\": \"次活動\",\n      \"emptyDay\": \"無活動\"\n    },\n    \"detail\": {\n      \"title\": \"當天明細\",\n      \"empty\": \"選擇一個日期查看當天的活動明細。\"\n    }\n  },\n  \"marks\": {\n    \"types\": {\n      \"screenshot\": \"截圖\",\n      \"text\": \"文本\",\n      \"image\": \"插圖\"\n    }\n  },\n  \"tags\": {\n    \"inspiration\": \"靈感\"\n  },\n  \"sync\": {\n    \"status\": \"同步倉庫狀態\",\n    \"imageRepo\": \"圖床倉庫\",\n    \"articleRepo\": \"文章倉庫\"\n  },\n  \"ai\": {\n    \"thinking\": \"思考\",\n    \"error\": {\n      \"title\": \"AI 錯誤\",\n      \"noAddress\": \"請先設置 AI 地址\"\n    }\n  },\n  \"article\": {\n    \"sync\": {\n      \"syncingRemote\": \"正在拉取遠端檔案...\",\n      \"syncComplete\": \"同步完成\",\n      \"pullingRemote\": \"正在從遠端伺服器獲取最新內容...\"\n    },\n    \"syncConfirm\": {\n      \"title\": \"檢測到遠端檔案更新\",\n      \"description\": \"檔案 {fileName} 有遠端更新\",\n      \"commitInfo\": \"最新提交資訊\",\n      \"commitMessage\": \"提交訊息\",\n      \"author\": \"作者\",\n      \"changes\": \"變更\",\n      \"confirmMessage\": \"確認要拉取遠端版本並覆蓋本地檔案嗎？此操作無法撤銷。\",\n      \"cancel\": \"取消\",\n      \"confirmPull\": \"確認拉取\"\n    },\n    \"emptyState\": {\n      \"title\": \"開始創作\",\n      \"subtitle\": \"選擇一個文件開始編輯，或創建新的筆記\",\n      \"tip\": \"💡 提示：你也可以從左側文件管理器中選擇文件\",\n      \"actions\": {\n        \"newNote\": {\n          \"title\": \"創建筆記\",\n          \"desc\": \"新建一篇 Markdown 筆記\"\n        },\n        \"newRecord\": {\n          \"title\": \"創建記錄\",\n          \"desc\": \"打開文本記錄功能\"\n        },\n        \"globalSearch\": {\n          \"title\": \"全局搜索\",\n          \"desc\": \"快速查找你的筆記內容\"\n        },\n        \"openWorkspace\": {\n          \"title\": \"打開工作區\",\n          \"desc\": \"選擇或切換工作區目錄\"\n        }\n      },\n      \"onboarding\": {\n        \"title\": \"新手引導\",\n        \"subtitle\": \"跟著這三步體驗 NoteGen 的核心工作流。\",\n        \"dismiss\": \"跳過新手引導\",\n        \"reopen\": \"重新顯示新手引導\",\n        \"start\": \"開始引導\",\n        \"viewHint\": \"查看提示\",\n        \"continue\": \"繼續下一步\",\n        \"completed\": \"已完成\",\n        \"allDone\": \"新手任務已全部完成，你已經體驗了 NoteGen 的基礎工作流。\",\n        \"stepLabel\": \"任務 ({current}/{total})\",\n        \"stepCompletedLabel\": \"已完成任務 ({current}/{total})\",\n        \"afterOrganizeDialog\": {\n          \"title\": \"已完成任務 (2/3)\",\n          \"description\": \"你已經把記錄整理成一篇筆記。要繼續體驗下一步，使用 AI Agent 將這篇筆記翻譯成雙語版本嗎？\",\n          \"confirm\": \"繼續下一步\",\n          \"cancel\": \"暫時不用\"\n        },\n        \"agentPrompt\": {\n          \"label\": \"示例提示詞\",\n          \"use\": \"使用這條提示詞\",\n          \"intro\": \"請將我剛剛整理出的這篇筆記直接修改為中英雙語版本。\",\n          \"requirement1\": \"\",\n          \"requirement2\": \"\",\n          \"requirement3\": \"\",\n          \"requirement4\": \"\",\n          \"outro\": \"\"\n        },\n        \"steps\": {\n          \"createRecord\": {\n            \"title\": \"建立第一條記錄\",\n            \"desc\": \"先記錄一段示例內容，熟悉記錄入口。\"\n          },\n          \"organizeNote\": {\n            \"title\": \"整理成筆記\",\n            \"desc\": \"把剛才的記錄整理成一篇正式筆記。\"\n          },\n          \"aiPolish\": {\n            \"title\": \"用 Agent 翻譯成雙語\",\n            \"desc\": \"使用 AI Agent 將剛整理好的筆記翻譯成雙語版本。\"\n          }\n        },\n        \"completedStates\": {\n          \"create-record\": {\n            \"title\": \"你已經建立了第一條記錄\",\n            \"desc\": \"現在你已經知道如何快速記錄內容了。\"\n          },\n          \"organize-note\": {\n            \"title\": \"你已經把記錄整理成筆記\",\n            \"desc\": \"下一步可以體驗 AI 如何繼續修改這篇筆記。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"你已經用 Agent 處理了筆記\",\n            \"desc\": \"你已經體驗了從記錄到整理，再到 Agent 處理內容的完整流程。\"\n          }\n        },\n        \"spotlight\": {\n          \"create-record\": {\n            \"title\": \"這裡可以快速記錄內容\",\n            \"desc\": \"點擊這個文字記錄入口，系統會自動帶入一段示例內容，你也可以自己修改。\"\n          },\n          \"organize-note\": {\n            \"title\": \"這裡可以整理記錄\",\n            \"desc\": \"點擊這個按鈕，把剛才的記錄整理成一篇正式筆記。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"這裡可以用 Agent 處理剛生成的筆記\",\n            \"desc\": \"將示例提示詞填入對話框後送出，Agent 會基於當前筆記生成雙語版本。\"\n          }\n        }\n      }\n    },\n    \"unsupportedFile\": {\n      \"title\": \"無法預覽此文件\",\n      \"fileName\": \"文件名\",\n      \"filePath\": \"文件路徑\",\n      \"fileSize\": \"文件大小\",\n      \"modifiedTime\": \"修改時間\",\n      \"createdTime\": \"創建時間\",\n      \"pathCopied\": \"路徑已複製\",\n      \"openExternal\": \"用外部程序打開\",\n      \"openDirectory\": \"打開文件目錄\"\n    },\n    \"file\": {\n      \"toolbar\": {\n        \"accessRepo\": \"訪問倉庫\",\n        \"loadingSync\": \"正在載入同步資訊\",\n        \"configSync\": \"配置同步\",\n        \"newArticle\": \"新建文章\",\n        \"newFolder\": \"新建文件夾\",\n        \"refresh\": \"刷新\",\n        \"toggleFolders\": \"文件夾展開摺疊\",\n        \"expandAll\": \"全部展開\",\n        \"collapseAll\": \"全部摺疊\",\n        \"sortByName\": \"按名稱排序\",\n        \"sortByCreated\": \"按創建時間排序\",\n        \"sortByModified\": \"按修改時間排序\",\n        \"sortAsc\": \"升序排列\",\n        \"sortDesc\": \"降序排列\",\n        \"sort\": \"排序\",\n        \"hideCloudFiles\": \"隱藏雲端檔案\",\n        \"showCloudFiles\": \"顯示雲端檔案\",\n        \"processingVectors\": \"正在處理向量數據\",\n        \"calculateVectors\": \"知識庫計算（全量）\",\n        \"importMarkdown\": \"匯入\",\n        \"importing\": \"正在匯入...\",\n        \"importSuccess\": \"匯入成功\",\n        \"importSuccessDesc\": \"成功匯入 {count} 個文件\",\n        \"importError\": \"匯入失敗\"\n      },\n      \"sync\": {\n        \"syncingRemote\": \"正在拉取遠端檔案\",\n        \"syncComplete\": \"同步完成\",\n        \"pullingRemote\": \"正在從遠端伺服器取得最新內容...\",\n        \"pullComplete\": \"拉取完成\"\n      },\n      \"context\": {\n        \"viewDirectory\": \"查看目錄\",\n        \"cut\": \"剪切\",\n        \"copy\": \"複製\",\n        \"paste\": \"黏貼\",\n        \"rename\": \"重命名\",\n        \"deleteSyncFile\": \"刪除同步文件\",\n        \"deleteLocalFile\": \"刪除本地文件\",\n        \"delete\": \"刪除\",\n        \"confirmDelete\": \"確定要刪除文件夾 \\\"{name}\\\" 嗎？此操作將刪除文件夾及其所有內容。\",\n        \"deleteSuccess\": \"刪除成功\",\n        \"deleteFailed\": \"刪除失敗\",\n        \"newFile\": \"新建文件\",\n        \"newFolder\": \"新建文件夾\",\n        \"syncFolder\": \"同步\",\n        \"syncFolderDesc\": \"同步當前文件夾下的所有 Markdown 文件\",\n        \"syncFolderSuccess\": \"文件夾同步成功\",\n        \"syncFolderError\": \"文件夾同步失敗\",\n        \"syncFolderProgress\": \"正在同步文件夾...\",\n        \"deleteSyncFileSuccess\": \"刪除成功\",\n        \"deleteSyncFileError\": \"刪除失敗\",\n        \"knowledgeBase\": \"知識庫\",\n        \"calculateVectors\": \"計算向量\",\n        \"updateVectors\": \"更新向量\",\n        \"deleteVectors\": \"刪除向量\",\n        \"includeInKB\": \"包含在知識庫\",\n        \"includeInKBFile\": \"包含在知識庫中\",\n        \"autoVectorCalc\": \"自動向量計算\",\n        \"vectorCalculated\": \"向量已更新\",\n        \"vectorCalcCompleted\": \"向量計算完成\",\n        \"vectorCalcFailed\": \"向量計算失敗\",\n        \"vectorDeleted\": \"向量已刪除\",\n        \"vectorDeleteFailed\": \"刪除向量失敗\",\n        \"batchCalcSuccess\": \"成功計算 {count} 個文件的向量\",\n        \"batchCalcPartial\": \"計算完成：成功 {success} 個，失敗 {failed} 個\",\n        \"batchCalcFailed\": \"批量計算向量失敗\",\n        \"batchDeleteSuccess\": \"成功刪除 {count} 個文件的向量\",\n        \"batchDeletePartial\": \"刪除完成：成功 {success} 個，失敗 {failed} 個\",\n        \"batchDeleteFailed\": \"批量刪除向量失敗\",\n        \"noMarkdownFiles\": \"文件夾中沒有 Markdown 文件\",\n        \"includedInKB\": \"已包含在知識庫中\",\n        \"excludedFromKB\": \"已從知識庫中排除\",\n        \"autoCalcEnabled\": \"已啟用自動向量計算\",\n        \"autoCalcDisabled\": \"已禁用自動向量計算\",\n        \"settingFailed\": \"設置失敗\",\n        \"confirmDeleteVectors\": \"確定要刪除 {count} 個文件的向量嗎？\"\n      },\n      \"folderView\": {\n        \"vectorDbNotEnabled\": \"向量資料庫未啟用\",\n        \"calculateVectors\": \"計算向量\",\n        \"indexed\": \"已計算\",\n        \"vectorCount\": \"向量數\",\n        \"databaseSize\": \"資料庫大小\",\n        \"lastCalculated\": \"最後計算\",\n        \"never\": \"從未\",\n        \"calculating\": \"計算中...\",\n        \"failed\": \"失敗\",\n        \"recalculateVectors\": \"重新計算向量\",\n        \"skills\": \"Skills\",\n        \"skillNotFound\": \"Skill 未找到\",\n        \"skillNotFoundDesc\": \"無法找到 ID 為 {id} 的 Skill\",\n        \"loadingSkills\": \"載入 Skills...\",\n        \"loadingSkill\": \"載入 Skill...\",\n        \"globalSkills\": \"全域 Skills\",\n        \"workspaceSkills\": \"工作區 Skills\",\n        \"instructions\": \"指令\",\n        \"examples\": \"示例\",\n        \"scripts\": \"腳本\",\n        \"references\": \"參考文檔\",\n        \"assets\": \"靜態資源\"\n      },\n      \"error\": {\n        \"fileExists\": \"檔案名已存在\"\n      },\n      \"clipboard\": {\n        \"copied\": \"已複製到剪貼簿\",\n        \"cut\": \"已剪切到剪貼簿\",\n        \"pasted\": \"已黏貼成功\",\n        \"pasteFailed\": \"黏貼操作失敗\",\n        \"empty\": \"剪貼簿為空\",\n        \"confirmOverwrite\": \"文件已存在，是否覆蓋？\",\n        \"notSupported\": \"不支持此操作\"\n      },\n      \"deleteConfirm\": \"確認刪除此文件嗎？\"\n    },\n    \"editor\": {\n      \"copySuccess\": \"複製成功\",\n      \"copySuccessDescription\": \"已複製到剪貼簿\",\n      \"search\": {\n        \"placeholder\": \"在文件中尋找\",\n        \"replacePlaceholder\": \"取代為\",\n        \"caseSensitive\": \"區分大小寫\",\n        \"replace\": \"取代\",\n        \"replaceAll\": \"全部取代\",\n        \"findPrev\": \"上一個\",\n        \"findNext\": \"下一個\"\n      },\n      \"floatbar\": {\n        \"quote\": {\n          \"tooltip\": \"引用\"\n        },\n        \"readAloud\": {\n          \"start\": \"朗讀\",\n          \"stop\": \"停止朗讀\",\n          \"loading\": \"正在載入...\"\n        }\n      },\n      \"upload\": {\n        \"error\": \"上傳失敗\",\n        \"needToken\": \"上傳圖片需配置 accessToken\",\n        \"uploading\": \"正在上傳圖片\"\n      },\n      \"toolbar\": {\n        \"organize\": {\n          \"tooltip\": \"整理筆記\"\n        },\n        \"mark\": {\n          \"title\": \"使用記錄\",\n          \"tooltip\": \"使用記錄\",\n          \"description\": \"消耗記錄轉化為內容插入到文章。\",\n          \"noRecords\": \"暫無記錄\",\n          \"ocrNoContent\": \"OCR 未識別到任何內容\"\n        },\n        \"question\": {\n          \"tooltip\": \"問答\",\n          \"selectContent\": \"請先選擇一段內容\",\n          \"promptTemplate\": \"參考原文：\\n{content}\\n根據提問：\\n{question}\\n，直接返回回答內容。\"\n        },\n        \"continue\": {\n          \"tooltip\": \"續寫\",\n          \"promptTemplate\": \"根據前文：\\n{content}\\n內容，直接返回續寫內容，不要超過100字。\\n內容可以參考後文：\\n{endContent}\\n，不要與後文內容重複。\"\n        },\n        \"polish\": {\n          \"tooltip\": \"潤色\",\n          \"selectContent\": \"請先選擇一段內容\",\n          \"promptTemplate\": \"潤色這段文字：\\n{content}\\n要求語言不變，修復錯別字和病句，直接返回潤色後的結果。\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"精簡\",\n          \"selectContent\": \"請先選擇一段內容\",\n          \"promptTemplate\": \"精簡這段文字：\\n{content}\\n這段文字過於臃腫，字數要求縮減一半以上，要求語言不變，直接返回最佳化後的結果。\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"擴寫\",\n          \"selectContent\": \"請先選擇一段內容\",\n          \"promptTemplate\": \"擴寫這段文字：\\n{content}\\n這段文字過於簡短，字數要求增加一倍以上，要求語言不變，直接返回擴寫後的結果。\"\n        },\n        \"translation\": {\n          \"tooltip\": \"翻譯\",\n          \"description\": \"將選中的文本進行翻譯\",\n          \"selectContent\": \"請先選擇一段內容\",\n          \"promptTemplate\": \"將這段文字：\\n{content}\\n翻譯為{language}語言，直接返回翻譯後的結果。\",\n          \"fail\": \"翻譯失敗\",\n          \"failNoSelection\": \"請先選擇要翻譯的文本\",\n          \"translating\": \"翻譯中\",\n          \"translatingTo\": \"正在翻譯為 {language}...\",\n          \"success\": \"翻譯完成\",\n          \"successTo\": \"已翻譯為 {language}\",\n          \"customLanguage\": \"自定義語言...\",\n          \"customLanguagePlaceholder\": \"輸入目標語言，如：英語、日語等\",\n          \"customLanguageEmpty\": \"請輸入目標語言\",\n          \"customLanguageExample\": \"例如：英語、日語、法語等\"\n        }\n      },\n      \"saveDialog\": {\n        \"title\": \"保存文件\",\n        \"emptyContent\": \"内容为空\",\n        \"emptyContentDesc\": \"请先输入内容后再保存\",\n        \"success\": \"保存成功\",\n        \"successDesc\": \"文件已保存\",\n        \"error\": \"保存失败\",\n        \"errorDesc\": \"文件保存失败，请重试\"\n      }\n    },\n    \"footer\": {\n      \"wordCount\": \"字數\",\n      \"pull\": {\n        \"pull\": \"拉取\",\n        \"checking\": \"檢查更新中...\",\n        \"noUpdate\": \"無遠端更新\",\n        \"clickToPull\": \"點擊拉取遠端更新\",\n        \"pullSuccess\": \"拉取成功\",\n        \"pullFailed\": \"拉取失敗\",\n        \"ignored\": \"已忽略\",\n        \"ignoreUpdate\": \"忽略此更新\"\n      },\n      \"sync\": {\n        \"push\": \"推送\",\n        \"pushed\": \"已推送\",\n        \"syncing\": \"推送中\",\n        \"syncFailed\": \"推送失敗\",\n        \"checkNetworkOrToken\": \"請檢查網路連接或令牌是否正確\",\n        \"quickSync\": \"快速同步\"\n      },\n      \"history\": {\n        \"loadingHistory\": \"正在讀取歷史記錄\",\n        \"historyRecords\": \"歷史記錄\",\n        \"noHistory\": \"無歷史記錄\",\n        \"loading\": \"載入中\",\n        \"recordsCount\": \"條紀錄\",\n        \"filterQuickSync\": \"過濾快速同步\",\n        \"committedAt\": \"提交於\",\n        \"pull\": \"拉取\",\n        \"quickSync\": \"快速同步\"\n      },\n      \"vectorCalc\": {\n        \"tooltip\": {\n          \"default\": \"向量索引狀態\",\n          \"none\": \"點擊開始向量計算\",\n          \"indexed\": \"已索引\",\n          \"pending\": \"待更新，點擊立即計算\",\n          \"calculating\": \"計算中...\"\n        },\n        \"status\": {\n          \"calculating\": \"計算中\"\n        }\n      }\n    }\n  },\n  \"mobile\": {\n    \"chat\": {\n      \"drawer\": {\n        \"settings\": {\n          \"title\": \"對話設定\"\n        },\n        \"tools\": {\n          \"title\": \"工具\",\n          \"clearContext\": \"清除上下文\",\n          \"clearContextDesc\": \"清除對話上下文，保留聊天記錄\",\n          \"clearChat\": \"清空對話\",\n          \"clearChatDesc\": \"刪除所有聊天記錄\",\n          \"clear\": \"清除\",\n          \"newChat\": \"开始新对话\",\n          \"start\": \"开始\"\n        },\n        \"attachments\": {\n          \"title\": \"附件\",\n          \"gallery\": \"相簿\",\n          \"camera\": \"照相\",\n          \"file\": \"檔案\",\n          \"linkNote\": \"關聯筆記\"\n        }\n      }\n    }\n  },\n  \"mcp\": {\n    \"selectServers\": \"MCP 伺服器\",\n    \"searchServers\": \"搜索伺服器...\",\n    \"noServers\": \"未啟用 MCP 服務功能\",\n    \"noServersFound\": \"未找到匹配的伺服器\",\n    \"addServer\": \"添加伺服器...\",\n    \"goToSettings\": \"前往設置\",\n    \"close\": \"關閉\",\n    \"navigate\": \"選擇\",\n    \"confirm\": \"確認\",\n    \"tools\": \"個工具\",\n    \"connecting\": \"連線中\",\n    \"disconnected\": \"未連接\"\n  },\n  \"quickRecord\": {\n    \"description\": \"點擊選擇記錄工具，快速創建記錄\"\n  },\n  \"recording\": {\n    \"title\": \"录音识别\",\n    \"description\": \"点击麦克风按钮开始录音，系统将自动识别并转换为文字记录\",\n    \"recording\": \"录音中\",\n    \"paused\": \"已暂停\",\n    \"ready\": \"准备就绪\",\n    \"processing\": \"识别中...\",\n    \"cancel\": \"取消\",\n    \"error\": \"错误\",\n    \"success\": \"成功\",\n    \"noModelConfigured\": \"未配置语音识别模型，请先在设置中配置\",\n    \"speechUnavailable\": \"當前識別方式不可用，請檢查本地語音支援或模型配置\",\n    \"fallbackToModel\": \"本地語音識別不可用，已自動切換為模型識別\",\n    \"startError\": \"无法启动录音\",\n    \"noAudioData\": \"未录制到音频数据\",\n    \"transcriptionSuccess\": \"语音识别完成\",\n    \"transcriptionEmpty\": \"识别结果为空\",\n    \"transcriptionError\": \"语音识别失败\",\n    \"configureModel\": \"配置模型\",\n    \"retryTranscription\": \"重新識別\",\n    \"retrying\": \"重新識別中...\",\n    \"retrySuccess\": \"重新識別完成\",\n    \"retryError\": \"重新識別失敗\",\n    \"noContentDetected\": \"未识别到内容\",\n    \"doubleClickToSelectFile\": \"双击选择音频文件\",\n    \"mode\": {\n      \"builtin\": \"浏览器识别\",\n      \"builtinDesc\": \"免费，实时识别\",\n      \"model\": \"大模型识别\",\n      \"modelDesc\": \"需配置STT模型，更准确\"\n    }\n  },\n  \"editor\": {\n    \"placeholder\": \"輸入 / 叫出選單，或直接開始寫作...\",\n    \"outline\": {\n      \"title\": \"大綱\",\n      \"open\": \"打開大綱\",\n      \"close\": \"關閉大綱\"\n    },\n    \"translation\": {\n      \"fail\": \"翻譯失敗\",\n      \"failNoSelection\": \"請先選擇要翻譯的文本\",\n      \"translating\": \"翻譯中...\",\n      \"translatingTo\": \"正在翻譯為 {language}...\",\n      \"success\": \"翻譯完成\",\n      \"successTo\": \"已翻譯為 {language}\",\n      \"customLanguageEmpty\": \"請輸入目標語言\",\n      \"customLanguageExample\": \"例如：英語、日語、法語等\"\n    },\n    \"quoteDisplay\": {\n      \"fromFile\": \"引用自 {fileName}\",\n      \"line\": \"引用自 {fileName} 第 {line} 行\",\n      \"lines\": \"引用自 {fileName} 第 {start}-{end} 行\"\n    },\n    \"bubbleMenu\": {\n      \"ai\": \"AI\",\n      \"polish\": \"潤色\",\n      \"concise\": \"精簡\",\n      \"expand\": \"擴展\",\n      \"translate\": \"翻譯\",\n      \"translateSubtitle\": \"翻譯為\",\n      \"quoteToChat\": \"引用到對話\",\n      \"link\": \"連結\",\n      \"linkPlaceholder\": \"輸入連結地址\",\n      \"confirm\": \"確認\",\n      \"cancel\": \"取消\",\n      \"bold\": \"粗體\",\n      \"italic\": \"斜體\",\n      \"strike\": \"刪除線\",\n      \"underline\": \"底線\",\n      \"inlineCode\": \"行內代碼\",\n      \"highlight\": \"高亮\",\n      \"blockquote\": \"引用\",\n      \"bulletList\": \"無序列表\",\n      \"orderedList\": \"有序列表\",\n      \"taskList\": \"任務列表\",\n      \"codeBlock\": \"代碼塊\",\n      \"languages\": {\n        \"English\": \"英語\",\n        \"Japanese\": \"日語\",\n        \"Korean\": \"韓語\",\n        \"French\": \"法語\",\n        \"German\": \"德語\",\n        \"Spanish\": \"西班牙語\",\n        \"Portuguese\": \"葡萄牙語\",\n        \"Russian\": \"俄語\",\n        \"Arabic\": \"阿拉伯語\"\n      },\n      \"customLanguagePlaceholder\": \"自定義語言...\"\n    },\n    \"aiSuggestion\": {\n      \"accept\": \"接受\",\n      \"reject\": \"拒絕\",\n      \"generating\": \"生成中...\",\n      \"abort\": \"終止\"\n    },\n    \"image\": {\n      \"insert\": \"插入圖片\",\n      \"uploading\": \"上傳中...\",\n      \"uploadSuccess\": \"圖片已上傳到圖床\",\n      \"saveSuccess\": \"圖片已保存到本地\",\n      \"uploadFailed\": \"插入圖片失敗\",\n      \"sizeSmall\": \"小 (25%)\",\n      \"sizeMedium\": \"中 (50%)\",\n      \"sizeLarge\": \"大 (75%)\",\n      \"sizeOriginal\": \"原始尺寸\",\n      \"editAlt\": \"編輯替代文本\",\n      \"editSrc\": \"編輯地址\",\n      \"altPlaceholder\": \"輸入替代文本...\",\n      \"srcPlaceholder\": \"輸入圖片地址...\",\n      \"delete\": \"刪除圖片\",\n      \"confirm\": \"確認\",\n      \"cancel\": \"取消\"\n    },\n    \"mermaid\": {\n      \"rendering\": \"渲染中...\",\n      \"renderError\": \"渲染錯誤\",\n      \"clickToEdit\": \"點擊編輯源碼\",\n      \"clickToAdd\": \"點擊添加圖表\",\n      \"placeholder\": \"輸入 Mermaid 圖表代碼...\",\n      \"preview\": \"預覽\",\n      \"done\": \"完成\",\n      \"diagramTypes\": {\n        \"flowchart\": \"流程圖\",\n        \"sequence\": \"時序圖\",\n        \"classDiagram\": \"類圖\",\n        \"stateDiagram\": \"狀態圖\",\n        \"er\": \"ER圖\",\n        \"gantt\": \"甘特圖\",\n        \"pie\": \"餅圖\",\n        \"journey\": \"旅程圖\"\n      },\n      \"templates\": {\n        \"flowchart\": \"graph TD\\n    A[開始] --> B[處理]\\n    B --> C[結束]\",\n        \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: 你好\\n    Bob-->>Alice: 回覆\",\n        \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n        \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n        \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n        \"gantt\": \"gantt\\n    title 項目計劃\\n    dateFormat YYYY-MM-DD\\n    section 第一階段\\n    任務1 :a1, 2024-01-01, 30d\\n    section 第二階段\\n    任務2 :after a1, 20d\",\n        \"pie\": \"pie title 資源分配\\n    \\\"CPU\\\" : 45\\n    \\\"記憶體\\\" : 30\\n    \\\"儲存\\\" : 25\",\n        \"journey\": \"journey\\n    title 我的日常工作\\n    section 上午\\n    通勤 : 7:00, 5\\n    工作 : 9:00, 8\"\n      }\n    },\n    \"slashCommand\": {\n      \"groups\": {\n        \"ai\": \"AI\",\n        \"heading\": \"標題\",\n        \"list\": \"列表\",\n        \"block\": \"區塊\",\n        \"align\": \"對齊\",\n        \"embed\": \"嵌入\",\n        \"math\": \"數學\",\n        \"chart\": \"圖表\"\n      },\n      \"items\": {\n        \"continue\": \"續寫\",\n        \"continueDesc\": \"AI 續寫內容\",\n        \"heading1\": \"標題1\",\n        \"heading1Desc\": \"大標題\",\n        \"heading2\": \"標題2\",\n        \"heading2Desc\": \"中標題\",\n        \"heading3\": \"標題3\",\n        \"heading3Desc\": \"小標題\",\n        \"bulletList\": \"無序列表\",\n        \"bulletListDesc\": \"建立簡單的項目列表\",\n        \"orderedList\": \"有序列表\",\n        \"orderedListDesc\": \"建立帶編號的列表\",\n        \"taskList\": \"任務列表\",\n        \"taskListDesc\": \"建立帶核取方塊的任務列表\",\n        \"image\": \"圖片\",\n        \"imageDesc\": \"插入本地圖片或圖床圖片\",\n        \"table\": \"表格\",\n        \"tableDesc\": \"插入表格\",\n        \"blockquote\": \"引用\",\n        \"blockquoteDesc\": \"擷取引用內容\",\n        \"codeBlock\": \"程式碼區塊\",\n        \"codeBlockDesc\": \"擷取程式碼片段\",\n        \"divider\": \"分隔線\",\n        \"dividerDesc\": \"在元素之間建立分隔線\",\n        \"inlineMath\": \"行內公式\",\n        \"inlineMathDesc\": \"插入行內 LaTeX 公式\",\n        \"blockMath\": \"區塊公式\",\n        \"blockMathDesc\": \"插入區塊 LaTeX 公式\",\n        \"flowchart\": \"流程圖\",\n        \"flowchartDesc\": \"插入流程圖\",\n        \"sequence\": \"時序圖\",\n        \"sequenceDesc\": \"插入時序圖\",\n        \"gantt\": \"甘特圖\",\n        \"ganttDesc\": \"插入甘特圖\",\n        \"classDiagram\": \"類別圖\",\n        \"classDiagramDesc\": \"插入類別圖\",\n        \"stateDiagram\": \"狀態圖\",\n        \"stateDiagramDesc\": \"插入狀態圖\",\n        \"pie\": \"圓餅圖\",\n        \"pieDesc\": \"插入圓餅圖\",\n        \"erDiagram\": \"ER圖\",\n        \"erDiagramDesc\": \"插入實體關係圖\",\n        \"journey\": \"旅程圖\",\n        \"journeyDesc\": \"插入使用者旅程圖\"\n      },\n      \"imageUpload\": {\n        \"success\": \"上傳成功\",\n        \"saveSuccess\": \"儲存成功\",\n        \"savePath\": \"儲存路徑: {path}\",\n        \"failed\": \"插入圖片失敗\"\n      }\n    }\n  },\n  \"tabContext\": {\n    \"close\": \"關閉\",\n    \"closeOthers\": \"關閉其他\",\n    \"closeAll\": \"關閉全部\",\n    \"closeLeft\": \"關閉左側\",\n    \"closeRight\": \"關閉右側\"\n  }\n}\n"
  },
  {
    "path": "messages/zh.json",
    "content": "{\n  \"app\": {\n    \"title\": \"笔记生成器\",\n    \"description\": \"你的 AI 驱动的笔记助手\"\n  },\n  \"common\": {\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"delete\": \"删除\",\n    \"confirm\": \"确认\",\n    \"edit\": \"编辑\",\n    \"create\": \"创建\",\n    \"theme\": \"主题\",\n    \"light\": \"亮色\",\n    \"dark\": \"暗色\",\n    \"system\": \"跟随系统\",\n    \"pin\": \"置顶\",\n    \"unpin\": \"取消置顶\",\n    \"settings\": \"设置\",\n    \"sync\": \"同步\",\n    \"language\": \"语言\",\n    \"success\": \"成功\",\n    \"error\": \"失败\",\n    \"defaultFileName\": \"未命名文档\",\n    \"back\": \"返回\",\n    \"close\": \"关闭\",\n    \"open\": \"打开\",\n    \"add\": \"添加\",\n    \"remove\": \"移除\",\n    \"search\": \"搜索\",\n    \"filter\": \"筛选\",\n    \"sort\": \"排序\",\n    \"export\": \"导出\",\n    \"import\": \"导入\",\n    \"refresh\": \"刷新\",\n    \"loading\": \"加载中...\",\n    \"all\": \"全部\",\n    \"today\": \"今天\",\n    \"yesterday\": \"昨天\",\n    \"warning\": \"警告\",\n    \"info\": \"信息\",\n    \"restartToApply\": \"，请重启应用使配置生效\",\n    \"unsaved\": \"未保存\",\n    \"saving\": \"保存中...\",\n    \"configureSync\": \"配置同步\"\n  },\n  \"settings\": {\n    \"defaultModels\": {\n      \"title\": \"默认模型\"\n    },\n    \"others\": \"高级\",\n    \"general\": {\n      \"title\": \"常规设置\",\n      \"desc\": \"在这里，你可以配置应用的基本设置，包括界面主题、语言等选项。\",\n      \"interface\": {\n        \"title\": \"界面设置\",\n        \"theme\": {\n          \"title\": \"主题\",\n          \"desc\": \"选择应用的外观主题\",\n          \"options\": {\n            \"light\": \"亮色\",\n            \"dark\": \"暗色\",\n            \"system\": \"跟随系统\"\n          }\n        },\n        \"language\": {\n          \"title\": \"语言\",\n          \"desc\": \"选择应用的显示语言\"\n        },\n        \"scale\": {\n          \"title\": \"界面缩放\",\n          \"desc\": \"调整应用界面的整体缩放比例\",\n          \"placeholder\": \"选择缩放比例\"\n        },\n        \"contentTextScale\": {\n          \"title\": \"正文缩放\",\n          \"desc\": \"调整编辑器和对话中 Markdown 内容的文字大小\"\n        },\n        \"fileManagerTextSize\": {\n          \"title\": \"文件管理器文字大小\",\n          \"desc\": \"调整文件管理器中文件和文件夹列表的文字大小\"\n        },\n        \"recordTextSize\": {\n          \"title\": \"记录文字大小\",\n          \"desc\": \"调整记录列表中记录项的文字大小\"\n        },\n        \"customCss\": {\n          \"title\": \"自定义 CSS\",\n          \"desc\": \"添加自定义 CSS 样式来覆盖应用的默认样式\",\n          \"button\": \"编辑 CSS\",\n          \"dialogTitle\": \"自定义 CSS\",\n          \"dialogDesc\": \"在下方输入自定义 CSS 代码，可以覆盖应用的默认样式。修改后点击保存即可生效。\",\n          \"placeholder\": \"在此输入自定义 CSS 代码。\",\n          \"save\": \"保存\",\n          \"cancel\": \"取消\"\n        },\n        \"customTheme\": {\n          \"title\": \"自定义主题颜色\",\n          \"desc\": \"自定义应用的主题颜色，包括背景色、前景色、边框色等\",\n          \"button\": \"编辑颜色\",\n          \"dialogTitle\": \"自定义主题颜色\",\n          \"dialogDesc\": \"配置自定义主题颜色。颜色更改会实时保存并生效，同时覆盖亮色和暗色主题。\",\n          \"close\": \"关闭\",\n          \"reset\": \"重置全部\",\n          \"tabs\": {\n            \"custom\": \"自定义\",\n            \"presets\": \"预设方案\",\n            \"importExport\": \"导入导出\"\n          },\n          \"export\": {\n            \"title\": \"导出配色方案\",\n            \"button\": \"生成导出代码\",\n            \"placeholder\": \"点击生成按钮将当前配色导出为代码\"\n          },\n          \"import\": {\n            \"title\": \"导入配色方案\",\n            \"button\": \"导入配色\",\n            \"placeholder\": \"粘贴配色方案的 JSON 代码\"\n          },\n          \"colors\": {\n            \"background\": \"背景色\",\n            \"foreground\": \"前景色\",\n            \"card\": \"卡片背景色\",\n            \"cardForeground\": \"卡片前景色\",\n            \"primary\": \"主色调\",\n            \"primaryForeground\": \"主色调前景色\",\n            \"secondary\": \"次要色调\",\n            \"secondaryForeground\": \"次要色调前景色\",\n            \"third\": \"第三色调\",\n            \"thirdForeground\": \"第三色调前景色\",\n            \"muted\": \"柔和色\",\n            \"mutedForeground\": \"柔和色前景色\",\n            \"accent\": \"强调色\",\n            \"accentForeground\": \"强调色前景色\",\n            \"border\": \"边框色\",\n            \"shadow\": \"阴影色\"\n          },\n          \"presets\": {\n            \"apply\": \"应用\",\n            \"reset\": {\n              \"name\": \"恢复默认\"\n            },\n            \"default\": {\n              \"name\": \"默认白色\"\n            },\n            \"ocean\": {\n              \"name\": \"海洋蓝\"\n            },\n            \"forest\": {\n              \"name\": \"森林绿\"\n            },\n            \"sunset\": {\n              \"name\": \"日落红\"\n            },\n            \"lavender\": {\n              \"name\": \"薰衣草紫\"\n            },\n            \"midnight\": {\n              \"name\": \"午夜暗\"\n            },\n            \"deepSea\": {\n              \"name\": \"深海蓝\"\n            },\n            \"darkForest\": {\n              \"name\": \"暗夜绿\"\n            },\n            \"darkViolet\": {\n              \"name\": \"紫罗兰暗\"\n            },\n            \"coralWarm\": {\n              \"name\": \"珊瑚暖\"\n            },\n            \"slateGray\": {\n              \"name\": \"石板灰\"\n            },\n            \"darkGold\": {\n              \"name\": \"暗夜金\"\n            },\n            \"beigeWarm\": {\n              \"name\": \"米黄暖\"\n            },\n            \"beigeDark\": {\n              \"name\": \"米黄暗\"\n            }\n          }\n        },\n        \"tray\": {\n          \"enabled\": {\n            \"title\": \"启用托盘\",\n            \"desc\": \"关闭窗口时选择最小化到托盘或直接关闭软件\"\n          }\n        }\n      },\n      \"tools\": {\n        \"title\": \"工具设置\",\n        \"desc\": \"配置各种工具栏按钮的显示和排序\",\n        \"chatToolbar\": {\n          \"title\": \"对话工具栏\",\n          \"desc\": \"自定义对话工具栏按钮的显示顺序和可见性\",\n          \"button\": \"设置\",\n          \"dialogTitle\": \"配置对话工具栏\",\n          \"dialogDesc\": \"拖动工具调整排序，使用开关控制显示或隐藏\",\n          \"groups\": {\n            \"pc\": \"PC 端\",\n            \"mobile\": \"移动端\",\n            \"bottom\": \"底部工具栏\",\n            \"topLeft\": \"顶部工具栏 - 左侧\",\n            \"topRight\": \"顶部工具栏 - 右侧\"\n          }\n        },\n        \"recordToolbar\": {\n          \"title\": \"记录工具栏\",\n          \"desc\": \"自定义记录工具栏按钮的显示顺序和可见性\",\n          \"button\": \"设置\",\n          \"dialogTitle\": \"配置记录工具栏\",\n          \"dialogDesc\": \"拖动工具调整排序，使用开关控制显示或隐藏\"\n        }\n      }\n    },\n    \"rag\": {\n      \"title\": \"知识库\",\n      \"desc\": \"在这里，你可以配置知识库相关设置，知识库基于 RAG 技术，通过嵌入模型将文本转换为向量，然后通过向量搜索来实现智能搜索和智能回答。\",\n      \"settingsTitle\": \"参数设置\",\n      \"settingsDesc\": \"通过调解参数，可以更加精确的控制知识库的检索效果。\",\n      \"deleteVectorConfirm\": \"确定清空知识库吗？\",\n      \"deleteVectorSuccess\": \"清空知识库成功\",\n      \"enable\": \"启用知识库检索\",\n      \"enableDesc\": \"启用后，AI 将在回答问题时检索你的笔记内容，提供更准确的回答。\",\n      \"topPDesc\": \"Top P 参数控制模型生成文本的多样性，值越小生成的文本越确定，值越大生成的文本越多样。\",\n      \"chunkSize\": \"分块大小\",\n      \"chunkSizeDesc\": \"文本分块的最大字符数，较大的分块可能包含更多上下文，但会增加向量计算的复杂度。\",\n      \"chunkOverlap\": \"重叠大小\",\n      \"chunkOverlapDesc\": \"文本分块间的重叠字符数，较大的重叠可以保持上下文连贯性。\",\n      \"resultCount\": \"检索数量\",\n      \"resultCountDesc\": \"检索时返回的相关文档数量，数量越多提供的信息可能更丰富，但也可能引入噪声。\",\n      \"similarityThreshold\": \"相似度阈值\",\n      \"similarityThresholdDesc\": \"文档与查询的最小相似度阈值，只有超过此阈值的文档才会被返回。值范围 0.0-1.0，越高要求越严格。\",\n      \"resetToDefaults\": \"重置默认值\",\n      \"deleteVector\": \"清空知识库\"\n    },\n    \"mcp\": {\n      \"title\": \"MCP\",\n      \"desc\": \"Model Context Protocol 允许 AI 调用外部工具和访问资源，扩展 AI 的能力边界。\",\n      \"enableTitle\": \"启用 MCP 功能\",\n      \"enableDesc\": \"启用后，AI 可以调用配置的 MCP 服务器提供的工具。\",\n      \"servers\": \"服务器列表\",\n      \"serversDesc\": \"管理 MCP 服务器配置，每个服务器可以提供不同的工具和资源。\",\n      \"addServer\": \"添加服务器\",\n      \"addFirstServer\": \"添加第一个服务器\",\n      \"editServer\": \"编辑服务器\",\n      \"serverName\": \"服务器名称\",\n      \"serverNamePlaceholder\": \"例如：文件系统服务器\",\n      \"serverEnabled\": \"启用服务器\",\n      \"serverEnabledDesc\": \"启用后，此服务器将自动连接并提供工具。\",\n      \"serverType\": \"服务器类型\",\n      \"stdio\": \"本地命令\",\n      \"http\": \"HTTP 服务\",\n      \"command\": \"命令\",\n      \"args\": \"参数\",\n      \"argsDesc\": \"命令行参数，用空格分隔\",\n      \"env\": \"环境变量\",\n      \"envDesc\": \"JSON 格式的环境变量配置\",\n      \"url\": \"服务地址\",\n      \"headers\": \"请求头\",\n      \"headersDesc\": \"JSON 格式的 HTTP 请求头\",\n      \"testConnection\": \"测试连接\",\n      \"test\": \"测试\",\n      \"testSuccess\": \"连接测试成功\",\n      \"testFailed\": \"连接测试失败\",\n      \"connected\": \"已连接\",\n      \"connecting\": \"连接中\",\n      \"disconnected\": \"未连接\",\n      \"error\": \"错误\",\n      \"tools\": \"工具\",\n      \"noServers\": \"未启用 MCP 服务功能\",\n      \"noServersFound\": \"未找到匹配的服务器\",\n      \"serverAdded\": \"服务器添加成功\",\n      \"serverUpdated\": \"服务器更新成功\",\n      \"serverDeleted\": \"服务器删除成功\",\n      \"deleteServerTitle\": \"删除服务器\",\n      \"deleteServerDesc\": \"确定要删除这个服务器吗？此操作无法撤销。\",\n      \"nameRequired\": \"请输入服务器名称\",\n      \"commandRequired\": \"请输入命令\",\n      \"urlRequired\": \"请输入服务地址\",\n      \"toolBrowser\": \"工具浏览器\",\n      \"searchTools\": \"搜索工具...\",\n      \"noToolsFound\": \"未找到工具\",\n      \"parameters\": \"参数\",\n      \"testAll\": \"测试所有连接\",\n      \"testAllCompleted\": \"所有连接测试完成\",\n      \"testAllFailed\": \"连接测试失败\",\n      \"save\": \"保存\",\n      \"cancel\": \"取消\",\n      \"delete\": \"删除\",\n      \"importJson\": \"导入 JSON\",\n      \"jsonImportTitle\": \"从 JSON 导入服务器配置\",\n      \"jsonImportDesc\": \"粘贴 MCP 服务器的 mcpServers 配置格式\",\n      \"jsonInput\": \"JSON 配置\",\n      \"jsonInputHelp\": \"支持 mcpServers 格式，会自动使用服务器名称作为 key\",\n      \"jsonRequired\": \"请输入 JSON 配置\",\n      \"jsonEmpty\": \"JSON 配置不能为空\",\n      \"jsonInvalidJson\": \"JSON 格式错误\",\n      \"jsonInvalidFormat\": \"配置格式无效，必须包含 name 和 type 字段\",\n      \"jsonInvalidType\": \"服务器类型必须是 stdio 或 http\",\n      \"jsonMissingCommand\": \"stdio 类型服务器必须指定 command\",\n      \"jsonMissingUrl\": \"http 类型服务器必须指定 url\",\n      \"jsonImportSuccess\": \"成功导入 {count} 个服务器\",\n      \"jsonImportSkipped\": \"跳过 {count} 个已存在的服务器\",\n      \"jsonImportNoServers\": \"没有导入任何服务器\",\n      \"import\": \"导入\",\n      \"mobileHttpOnlyTitle\": \"本地命令 MCP 仅限桌面端\",\n      \"mobileHttpOnlyDesc\": \"本地命令型 MCP 服务器仅支持桌面端，移动端当前只支持 HTTP MCP。\",\n      \"runtimeEnvironment\": \"运行时环境\",\n      \"runtimeEnvironmentDesc\": \"在测试 MCP 服务器前，先检查所需的本地运行时是否可用。\",\n      \"checkEnvironment\": \"检查环境\",\n      \"recheckEnvironment\": \"重新检查环境\",\n      \"runtimeCheckFailed\": \"环境检查失败\",\n      \"detectedLauncher\": \"检测到的启动器\",\n      \"runtimeInstalled\": \"已安装\",\n      \"runtimeMissing\": \"缺失\",\n      \"runtimeVersion\": \"版本\",\n      \"runtimeInstalledSummary\": \"已安装 {installed}/{total}\",\n      \"showRuntimeDetails\": \"展开运行时详情\",\n      \"hideRuntimeDetails\": \"收起运行时详情\",\n      \"runtimeNotChecked\": \"尚未检查该运行时。\",\n      \"runtimeCurrentUserScope\": \"推荐命令会尽量安装到当前用户环境。\",\n      \"runtimeManualOnly\": \"当前平台暂不支持自动安装该运行时，请手动安装后重新检查。\",\n      \"installRuntime\": \"安装运行时\",\n      \"runtimeInstallTitle\": \"安装运行时\",\n      \"runtimeInstallDesc\": \"确认后，NoteGen 将执行以下安装命令。\",\n      \"runtimeInstallPreparing\": \"准备安装\",\n      \"runtimeInstallRunning\": \"安装中\",\n      \"runtimeInstallCompleted\": \"安装完成\",\n      \"runtimeInstallCancelled\": \"已取消\",\n      \"runtimeInstallFailedState\": \"安装失败\",\n      \"runtimeInstallLogs\": \"安装日志\",\n      \"runtimeInstallWaitingLogs\": \"等待安装输出...\",\n      \"runtimeInstallClose\": \"关闭\",\n      \"runtimeInstallCancel\": \"终止安装\",\n      \"runtimeInstallCancelledByUser\": \"用户已请求取消安装。\",\n      \"runtimeInstallCancelFailed\": \"终止安装失败\",\n      \"runtimeInstallSuccess\": \"运行时安装完成\",\n      \"runtimeInstallFailed\": \"运行时安装失败\",\n      \"runtimeNoGuidedSupport\": \"当前命令暂不支持引导式运行时辅助。\"\n    },\n    \"skills\": {\n      \"title\": \"Skills\",\n      \"desc\": \"Skills 是可重用的 AI 能力包，让 AI 助手能够根据任务自动应用特定的行为模式。\",\n      \"enable\": \"启用 Skills 功能\",\n      \"enableDesc\": \"启用后，AI 可以使用已配置的 Skills\",\n      \"autoMatch\": \"自动匹配 Skills\",\n      \"autoMatchDesc\": \"根据用户输入自动选择合适的 Skills\",\n      \"project\": \"工作区 Skills\",\n      \"global\": \"全局 Skills\",\n      \"globalPath\": \"全局 Skills 存储位置\",\n      \"openInFileManager\": \"在文件管理器中打开\",\n      \"createSkill\": \"创建 Skill\",\n      \"editSkill\": \"编辑 Skill\",\n      \"deleteSkill\": \"删除 Skill\",\n      \"exportSkill\": \"导出 Skill\",\n      \"importSkill\": \"导入 Skill\",\n      \"selectSkillZip\": \"选择 Skill zip 文件\",\n      \"importSuccess\": \"导入成功\",\n      \"importError\": \"导入失败\",\n      \"imported\": \"已导入\",\n      \"importing\": \"导入中...\",\n      \"skillName\": \"Skill 名称\",\n      \"skillDescription\": \"描述\",\n      \"skillVersion\": \"版本\",\n      \"skillAuthor\": \"作者\",\n      \"allowedTools\": \"允许使用的工具\",\n      \"userInvocable\": \"在斜杠菜单显示\",\n      \"instructions\": \"指令内容\",\n      \"instructionsPlaceholder\": \"输入给 AI 的详细指令...\",\n      \"importHelp\": \"支持导入 zip 格式的 Skill，zip 文件需包含 SKILL.md 文件。\",\n      \"metadata\": \"元数据\",\n      \"content\": \"指令内容\",\n      \"noSkills\": \"还没有 Skills\",\n      \"noSkillsDesc\": \"创建或导入 Skills 以开始使用\",\n      \"noSkillsGlobal\": \"还没有全局 Skills\",\n      \"noSkillsGlobalDesc\": \"创建或导入 Skills 以在所有项目中使用\",\n      \"emptyWorkspace\": \"工作区中没有 Skills\",\n      \"emptyWorkspaceDesc\": \"在 skills 文件夹中创建 SKILL.md 文件来添加 Skill\",\n      \"basicSettings\": \"基础设置\",\n      \"installedGlobalSkills\": \"已安装的全局 Skills\",\n      \"nameRequired\": \"请输入 Skill 名称\",\n      \"descriptionRequired\": \"请输入描述\",\n      \"namePlaceholder\": \"note-organizer\",\n      \"versionPlaceholder\": \"1.0.0\",\n      \"descriptionPlaceholder\": \"自动整理和优化笔记结构...\",\n      \"authorPlaceholder\": \"Your Name\",\n      \"descriptionHelp\": \"用于 AI 匹配，描述此 Skill 的功能和适用场景\",\n      \"allowedToolsHelp\": \"这些工具使用时不需要用户确认\",\n      \"userInvocableHelp\": \"用户可以通过 /skill-name 手动触发\",\n      \"instructionsHelp\": \"给 AI 的详细指令，支持 Markdown 格式\",\n      \"deleteSkillTitle\": \"删除 Skill\",\n      \"deleteSkillDesc\": \"确定要删除这个 Skill 吗？此操作无法撤销。\",\n      \"skillDeleted\": \"Skill 删除成功\"\n    },\n    \"editor\": {\n      \"title\": \"编辑器设置\",\n      \"desc\": \"在这里，你可以对编辑器进行自定义配置，打造更适合你的写作方式。\",\n      \"interfaceSettings\": \"界面设置\",\n      \"centeredContent\": \"居中内容\",\n      \"centeredContentDesc\": \"启用后，编辑器内容将在中间显示，两侧留白。\",\n      \"outlineEnable\": \"默认启用大纲\",\n      \"outlineEnableDesc\": \"启用后，编辑器将默认显示大纲。\",\n      \"outlinePosition\": \"大纲位置\",\n      \"outlinePositionDesc\": \"设置大纲位置。\",\n      \"outlinePositionOptions\": {\n        \"left\": \"左侧\",\n        \"right\": \"右侧\"\n      },\n      \"showUndoRedo\": \"撤销/重做按钮\",\n      \"showUndoRedoDesc\": \"在编辑器标签栏显示撤销和重做按钮。\",\n      \"completion\": {\n        \"title\": \"快速补全\",\n        \"model\": {\n          \"title\": \"快速补全模型\",\n          \"desc\": \"选择用于编辑器 AI 内联补全的模型\"\n        }\n      },\n      \"commit\": {\n        \"title\": \"自动生成 Commit\",\n        \"model\": {\n          \"title\": \"提交模型\",\n          \"desc\": \"用于自动生成 Git 提交信息，基于文件内容变化智能生成描述性提交信息\"\n        }\n      },\n      \"mermaid\": {\n        \"title\": \"图表\",\n        \"rendering\": \"渲染中...\",\n        \"renderError\": \"渲染错误\",\n        \"clickToEdit\": \"点击编辑源码\",\n        \"clickToAdd\": \"点击添加图表代码\",\n        \"placeholder\": \"输入 Mermaid 图表代码...\",\n        \"preview\": \"预览\",\n        \"done\": \"完成\",\n        \"diagramTypes\": {\n          \"flowchart\": \"流程图\",\n          \"sequence\": \"时序图\",\n          \"classDiagram\": \"类图\",\n          \"stateDiagram\": \"状态图\",\n          \"er\": \"ER图\",\n          \"gantt\": \"甘特图\",\n          \"pie\": \"饼图\",\n          \"journey\": \"旅程图\"\n        },\n        \"templates\": {\n          \"flowchart\": \"graph TD\\n    A[开始] --> B[处理]\\n    B --> C[结束]\",\n          \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: 你好\\n    Bob-->>Alice: 回复\",\n          \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n          \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n          \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n          \"gantt\": \"gantt\\n    title 项目计划\\n    dateFormat YYYY-MM-DD\\n    section 第一阶段\\n    任务1 :a1, 2024-01-01, 30d\\n    section 第二阶段\\n    任务2 :after a1, 20d\",\n          \"pie\": \"pie title 资源分配\\n    \\\"CPU\\\" : 45\\n    \\\"内存\\\" : 30\\n    \\\"存储\\\" : 25\",\n          \"journey\": \"journey\\n    title 我的日常工作\\n    section 上午\\n    通勤 : 7:00, 5\\n    工作 : 9:00, 8\"\n        }\n      }\n    },\n    \"record\": {\n      \"title\": \"记录设置\",\n      \"desc\": \"在这里，你可以配置记录相关的设置，包括记录描述和工具栏配置。\",\n      \"model\": {\n        \"title\": \"模型设置\",\n        \"markDesc\": {\n          \"title\": \"记录描述\",\n          \"desc\": \"用于处理 OCR 识别后的记录，生成记录描述\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"工具栏设置\",\n        \"recordToolbar\": {\n          \"title\": \"记录工具栏\",\n          \"desc\": \"自定义记录工具栏按钮的显示顺序和可见性\",\n          \"button\": \"设置\",\n          \"text\": {\n            \"desc\": \"记录文本内容\"\n          },\n          \"recording\": {\n            \"desc\": \"录音记录功能\"\n          },\n          \"scan\": {\n            \"desc\": \"扫描识别图片中的文字\"\n          },\n          \"image\": {\n            \"desc\": \"上传图片到笔记\"\n          },\n          \"link\": {\n            \"desc\": \"记录网页链接\"\n          },\n          \"file\": {\n            \"desc\": \"上传文件到笔记\"\n          },\n          \"todo\": {\n            \"desc\": \"创建待办事项\"\n          }\n        }\n      }\n    },\n    \"uploadStore\": {\n      \"uploadConfirm\": \"上传配置请确保同步仓库为私有，否则数据将会泄露！\",\n      \"downloadConfirm\": \"下载配置将会覆盖本地配置，并且重启生效！\",\n      \"uploadSuccess\": \"上传成功\",\n      \"downloadSuccess\": \"下载成功\",\n      \"upload\": \"上传配置\",\n      \"download\": \"下载配置\"\n    },\n    \"prompt\": {\n      \"title\": \"Prompt\",\n      \"promptTitle\": \"Prompt 名称\",\n      \"desc\": \"在这里，你可以添加和管理 Prompt，帮助 AI 更好地理解你的需求。\",\n      \"addPrompt\": \"新增 Prompt\",\n      \"selectPrompt\": \"选择 Prompt\",\n      \"configPrompt\": \"配置 Prompt\",\n      \"noContent\": \"暂无内容\",\n      \"addPromptDesc\": \"请输入 Prompt 名称和内容，帮助AI更好地理解你的需求。\",\n      \"promptTitlePlaceholder\": \"请输入 Prompt 名称\",\n      \"promptContentPlaceholder\": \"请输入 Prompt 内容\",\n      \"promptContent\": \"Prompt 内容\",\n      \"optimizePrompt\": \"优化提示词\",\n      \"optimizing\": \"优化中...\",\n      \"optimizeSuccess\": \"提示词优化成功\",\n      \"optimizeFailed\": \"提示词优化失败，请稍后重试\",\n      \"noContentToOptimize\": \"请先输入提示词内容\"\n    },\n    \"memories\": {\n      \"title\": \"记忆管理\",\n      \"desc\": \"AI 长期记忆功能，让 AI 记住你的写作偏好、经验知识和笔记习惯。\",\n      \"stats\": {\n        \"total\": \"总记忆数\",\n        \"preferences\": \"偏好\",\n        \"memories\": \"记忆\"\n      },\n      \"form\": {\n        \"title\": \"添加新记忆\",\n        \"categoryDescription\": \"记忆分为两种类型：\",\n        \"preferenceDescription\": \"偏好：语言、格式、风格等设置，每次对话都会自动加载\",\n        \"memoryDescription\": \"记忆：事实、经验、专长等信息，根据对话内容智能匹配\",\n        \"contentLabel\": \"记忆内容\",\n        \"contentPlaceholder\": \"例如：我喜欢用中文回答、我是React专家...\",\n        \"categoryLabel\": \"类型\",\n        \"preferenceLabel\": \"偏好\",\n        \"memoryLabel\": \"记忆\",\n        \"preferenceDesc\": \"语言、格式、风格等\",\n        \"memoryDesc\": \"事实、经验、专长等\",\n        \"save\": \"保存记忆\",\n        \"saving\": \"保存中...\"\n      },\n      \"listTitle\": \"我的记忆\",\n      \"addMemory\": \"添加记忆\",\n      \"empty\": \"暂无记忆，添加第一条记忆吧！\",\n      \"emptyHint\": \"你可以手动添加记忆，或在对话中使用「请记住」「记住这个」等话术让 AI 自动记忆。\",\n      \"preference\": \"偏好\",\n      \"memory\": \"记忆\",\n      \"replaced\": \"已替换\",\n      \"accessCount\": \"访问 {count} 次\",\n      \"tabs\": {\n        \"all\": \"全部\",\n        \"preference\": \"偏好\",\n        \"memory\": \"记忆\"\n      },\n      \"success\": \"成功\",\n      \"saved\": \"记忆已保存\",\n      \"updated\": \"记忆已更新（已替换相似记忆）\",\n      \"deleted\": \"记忆已删除\",\n      \"cleared\": \"所有记忆已清空\",\n      \"found\": \"找到 {count} 条记忆\",\n      \"error\": \"错误\",\n      \"errorEmpty\": \"请输入记忆内容\",\n      \"errorSave\": \"保存失败\",\n      \"errorDelete\": \"删除记忆失败\",\n      \"errorList\": \"获取记忆列表失败\",\n      \"errorEmbedding\": \"无法生成向量嵌入，请检查嵌入模型配置\",\n      \"errorClear\": \"清空记忆失败\"\n    },\n    \"defaultModel\": {\n      \"title\": \"默认模型\",\n      \"desc\": \"在这里，你可以针对不同的场景使用不同的模型，提高效率降低成本。\",\n      \"tooltip\": \"使用主要模型\",\n      \"noModel\": \"不使用模型\",\n      \"placeholder\": \"请选择或搜索模型\",\n      \"mainModel\": \"主要模型\",\n      \"options\": {\n        \"primaryModel\": {\n          \"title\": \"主要模型\",\n          \"desc\": \"作为所有场景的主要模型，如果其他对话模型未选择默认模型，则使用此模型。\"\n        },\n        \"markDesc\": {\n          \"title\": \"记录描述\",\n          \"desc\": \"用于处理 OCR 识别后的记录，生成记录描述。\"\n        },\n        \"placeholder\": {\n          \"title\": \"AI 建议\",\n          \"desc\": \"AI 建议提示作用于记录页面 AI 对话 placeholder 内容生成。\"\n        },\n        \"completion\": {\n          \"title\": \"快速补全\",\n          \"desc\": \"用于 Markdown 编辑器的 AI 内联补全，类似 GitHub Copilot，快速生成续写内容。\"\n        },\n        \"commit\": {\n          \"title\": \"自动生成 commit 信息\",\n          \"desc\": \"用于自动生成 Git 提交信息，基于文件内容变化智能生成描述性提交信息。\"\n        },\n        \"embedding\": {\n          \"title\": \"嵌入模型\",\n          \"desc\": \"用于处理文本嵌入和向量化的场景。\"\n        },\n        \"reranking\": {\n          \"title\": \"重排模型\",\n          \"desc\": \"用于搜索结果的重新排序和优化。\"\n        },\n        \"condense\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"用于压缩历史对话内容，节省 token 使用量\"\n        }\n      }\n    },\n    \"audio\": {\n      \"title\": \"语音设置\",\n      \"desc\": \"在这里，你可以配置语音相关设置，包括文本转语音（朗读）和语音转文本（录音识别）功能。\",\n      \"mode\": {\n        \"title\": \"运行方式\",\n        \"auto\": \"自动（推荐）\",\n        \"local\": \"仅本地\",\n        \"model\": \"仅模型\"\n      },\n      \"tts\": {\n        \"title\": \"文本转语音（TTS）\",\n        \"desc\": \"配置朗读功能，为聊天内容提供语音播放。\",\n        \"modeDesc\": \"默认优先使用浏览器和系统自带语音，必要时再使用模型增强体验。\",\n        \"model\": {\n          \"title\": \"朗读模型\",\n          \"desc\": \"可选。配置后可在自动模式下作为增强体验或在仅模型模式下使用。\"\n        },\n        \"speed\": {\n          \"title\": \"语速\",\n          \"desc\": \"调整语音播放的速度，范围从0.5倍到2倍速度，默认为1倍正常速度。\"\n        }\n      },\n      \"stt\": {\n        \"title\": \"语音转文本（STT）\",\n        \"desc\": \"配置录音识别功能，将语音转换为文字记录。\",\n        \"modeDesc\": \"默认优先使用浏览器原生识别，不支持时再回退到模型识别。\",\n        \"model\": {\n          \"title\": \"识别模型\",\n          \"desc\": \"可选。配置后可在自动模式下回退使用，或在仅模型模式下强制使用。\"\n        }\n      }\n    },\n    \"readAloud\": {\n      \"title\": \"朗读\",\n      \"desc\": \"配置文本朗读方式，默认优先使用系统语音，模型作为增强能力。\",\n      \"options\": {\n        \"mode\": {\n          \"title\": \"运行方式\",\n          \"desc\": \"自动模式会优先使用系统语音，不可用时再尝试模型。\",\n          \"auto\": \"自动（推荐）\",\n          \"local\": \"仅本地\",\n          \"model\": \"仅模型\"\n        },\n        \"audioModel\": {\n          \"title\": \"朗读模型\",\n          \"desc\": \"可选。配置后可在自动模式下作为增强体验，或在仅模型模式下使用。\"\n        },\n        \"speed\": {\n          \"title\": \"语速\",\n          \"desc\": \"调整朗读速度，范围从0.5倍到2倍，默认为1倍。\"\n        }\n      }\n    },\n    \"about\": {\n      \"title\": \"关于\",\n      \"desc\": \"一款专注于记录与写作的 AI 笔记。\",\n      \"version\": \"NoteGen v{version}\",\n      \"checkReleases\": \"查询历史版本\",\n      \"language\": \"语言\",\n      \"checkUpdate\": \"检查更新\",\n      \"checkError\": \"检查更新失败\",\n      \"updateAvailable\": \"更新至最新版本\",\n      \"updateDownloading\": \"更新中 {downloaded} / {contentLength}\",\n      \"updateInstalled\": \"重启应用\",\n      \"noUpdate\": \"当前已是最新版本\",\n      \"ignoreVersion\": \"忽略此版本\",\n      \"ignoreVersionSuccess\": \"已忽略此版本更新\",\n      \"items\": {\n        \"home\": {\n          \"title\": \"官网\",\n          \"buttonName\": \"打开\",\n          \"desc\": \"访问官网，了解 NoteGen 的更多信息。\"\n        },\n        \"guide\": {\n          \"title\": \"配置指南\",\n          \"buttonName\": \"打开\",\n          \"desc\": \"查看配置指南，了解如何配置模型、同步等信息。\"\n        },\n        \"github\": {\n          \"title\": \"GitHub\",\n          \"buttonName\": \"查看\",\n          \"desc\": \"如果 NoteGen 帮助到了你，请给颗 star 鼓励一下！\"\n        },\n        \"releases\": {\n          \"title\": \"更新日志\",\n          \"buttonName\": \"查看\",\n          \"desc\": \"查看更新日志，了解 NoteGen 的更新信息。\"\n        },\n        \"issues\": {\n          \"title\": \"问题反馈\",\n          \"buttonName\": \"反馈\",\n          \"desc\": \"如果发现 NoteGen 有 bug，请在这里反馈。\"\n        },\n        \"discussions\": {\n          \"title\": \"交流讨论\",\n          \"buttonName\": \"讨论\",\n          \"desc\": \"如果你想和作者或其他用户交流，可以加群讨论。\"\n        }\n      }\n    },\n    \"sync\": {\n      \"title\": \"同步配置\",\n      \"desc\": \"在这里，你可以配置同步仓库，它可以帮助你同步记录、markdown 文件、系统配置等信息。\",\n      \"selectPlatform\": \"选择同步平台\",\n      \"platformSettings\": \"选择平台\",\n      \"settings\": \"同步设置\",\n      \"platformDesc\": \"配置 Token 和仓库信息以启用同步功能\",\n      \"moreSettings\": \"更多设置\",\n      \"repoStatus\": \"仓库状态\",\n      \"syncRepo\": \"同步仓库\",\n      \"syncRepoDesc\": \"同步写作中的 markdown 文件。\",\n      \"imageRepo\": \"图床仓库\",\n      \"imageRepoDesc\": \"同步你的图片到图床仓库。\",\n      \"status\": {\n        \"connected\": \"已连接\",\n        \"disconnected\": \"未连接\",\n        \"failed\": \"连接失败\",\n        \"unconfigured\": \"未配置\"\n      },\n      \"uploadRecords\": \"上载记录和配置\",\n      \"downloadConfig\": \"下载记录和配置\",\n      \"cloudSync\": \"记录与配置同步\",\n      \"localBackupAll\": \"本地备份（全部）\",\n      \"private\": \"私有\",\n      \"public\": \"公开\",\n      \"createdAt\": \"创建于 {time}\",\n      \"updatedAt\": \"最后更新于 {time}\",\n      \"newToken\": \"创建 access token\",\n      \"newTokenDesc\": \"新建 token 时，请务必勾选 repo 权限，配置后将自动创建文件仓库（私有）和图床仓库。\",\n      \"giteeTokenDesc\": \"Gitee 私人令牌用于同步数据，需要有仓库的读写权限，配置后将自动创建文件仓库（私有）和图床仓库。\",\n      \"imageRepoSetting\": \"开启图床\",\n      \"imageRepoSettingDesc\": \"你已经配置了图床仓库，开启此项将使用图床仓库，否则将使用本地存储。\",\n      \"jsdelivrSetting\": \"jsDelivr\",\n      \"jsdelivrSettingDesc\": \"使用 jsdelivr 加速图片访问。\",\n      \"autoSyncDesc\": \"启用后，编辑器会在输入停止 10 秒后自动同步到 GitHub\",\n      \"giteeAutoSyncDesc\": \"启用后，编辑器会在输入停止 10 秒后自动同步到 Gitee\",\n      \"customSyncRepo\": \"自定义同步仓库名\",\n      \"customSyncRepoDesc\": \"留空则使用默认仓库名\",\n      \"customImageRepo\": \"自定义图床仓库名\",\n      \"customImageRepoDesc\": \"留空则使用默认仓库名\",\n      \"backupMethod\": \"备份方式\",\n      \"backupMethodDesc\": \"设置为主要备份方式后，写作中的所有同步相关功能将使用当前备份方式（图床功能除外）\",\n      \"createRepo\": \"创建仓库\",\n      \"creating\": \"创建中\",\n      \"checkRepo\": \"检查仓库\",\n      \"checking\": \"检查中\",\n      \"enterToken\": \"请输入 Access Token\",\n      \"enterTokenHint\": \"请先输入 Access Token 以检查仓库状态\",\n      \"defaultRepoName\": \"默认: {name}\",\n      \"gitlabInstanceType\": \"GitLab 实例类型\",\n      \"gitlabInstanceTypeDesc\": \"选择要连接的 GitLab 实例类型\",\n      \"gitlabInstanceTypePlaceholder\": \"选择 GitLab 实例类型\",\n      \"gitlabInstanceTypeOptions\": {\n        \"selfHosted\": \"自建实例\",\n        \"selfHostedDesc\": \"输入您的自建 GitLab 服务器地址（如：https://gitlab.example.com）\"\n      },\n      \"gitlabAccessTokenDesc\": \"在 {instanceDisplayName} 创建个人访问令牌，需要 api 权限\",\n      \"giteaInstanceType\": \"Gitea 实例类型\",\n      \"giteaInstanceTypeDesc\": \"选择要连接的 Gitea 实例类型\",\n      \"giteaInstanceTypePlaceholder\": \"选择 Gitea 实例类型\",\n      \"giteaInstanceTypeOptions\": {\n        \"selfHosted\": \"自建实例\",\n        \"selfHostedDesc\": \"输入您的自建 Gitea 服务器地址（如：https://gitea.example.com）\"\n      },\n      \"giteaAccessTokenDesc\": \"在 {instanceDisplayName} 创建个人访问令牌，需要完整的 repository 权限\",\n      \"s3\": {\n        \"title\": \"S3 同步\",\n        \"description\": \"使用 S3 兼容存储同步你的笔记\",\n        \"status\": \"连接状态\",\n        \"connected\": \"已连接\",\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"未连接\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"请输入 Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"请输入 Secret Access Key\",\n        \"region\": \"区域\",\n        \"bucket\": \"存储桶\",\n        \"bucketPlaceholder\": \"请输入存储桶名称\",\n        \"endpoint\": \"端点\",\n        \"pathPrefix\": \"路径前缀\",\n        \"pathPrefixPlaceholder\": \"请输入路径前缀\",\n        \"pathPrefixDesc\": \"用于区分不同用户的文件，类似仓库名\",\n        \"customDomain\": \"自定义域名\",\n        \"testConnection\": \"测试连接\",\n        \"testing\": \"测试中\",\n        \"saveConfig\": \"保存配置\",\n        \"saving\": \"保存中\"\n      },\n      \"webdav\": {\n        \"title\": \"WebDAV 同步\",\n        \"description\": \"使用 WebDAV 协议同步你的笔记\",\n        \"status\": \"连接状态\",\n        \"connected\": \"已连接\",\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"未连接\",\n        \"url\": \"服务器地址\",\n        \"urlPlaceholder\": \"请输入 WebDAV 服务器地址\",\n        \"urlDesc\": \"支持群晖、QNAP、Nextcloud 等 WebDAV 服务\",\n        \"username\": \"用户名\",\n        \"usernamePlaceholder\": \"请输入用户名\",\n        \"password\": \"密码\",\n        \"passwordPlaceholder\": \"请输入密码\",\n        \"pathPrefix\": \"路径前缀\",\n        \"pathPrefixPlaceholder\": \"请输入路径前缀\",\n        \"pathPrefixDesc\": \"用于区分不同用户的文件\",\n        \"testConnection\": \"测试连接\",\n        \"testing\": \"测试中\",\n        \"saveConfig\": \"保存配置\",\n        \"saving\": \"保存中\"\n      },\n      \"autoSync\": \"自动同步\",\n      \"autoSyncOptions\": {\n        \"placeholder\": \"选择自动同步时间\",\n        \"disabled\": \"关闭\",\n        \"2s\": \"2 秒\",\n        \"3s\": \"3 秒\",\n        \"5s\": \"5 秒\",\n        \"10s\": \"10 秒\",\n        \"20s\": \"20 秒\",\n        \"30s\": \"30 秒\",\n        \"1m\": \"1 分钟\",\n        \"2m\": \"2 分钟\"\n      },\n      \"autoPullOnOpen\": \"打开文件时自动拉取\",\n      \"autoPullOnOpenDesc\": \"打开文件时，如果远程有新版本则自动拉取覆盖本地\",\n      \"autoPullOnSwitch\": \"切换文件时自动拉取\",\n      \"autoPullOnSwitchDesc\": \"切换到其他文件时，如果远程有新版本则自动拉取覆盖本地\",\n      \"exclusions\": {\n        \"title\": \"同步排除配置\",\n        \"desc\": \"以下配置项不会在设备间同步，因为它们是设备特定的\",\n        \"workspacePath\": \"工作区路径\",\n        \"workspaceHistory\": \"工作区历史路径\",\n        \"assetsPath\": \"资源路径\",\n        \"uiScale\": \"界面缩放\",\n        \"contentTextScale\": \"正文文字缩放\",\n        \"customCss\": \"自定义 CSS\",\n        \"reason\": \"这些配置在不同设备上可能不同，不进行同步可以避免路径错误等问题\"\n      },\n      \"settingsSync\": {\n        \"uploadSuccess\": \"配置上传成功\",\n        \"uploadFailed\": \"配置上传失败\",\n        \"downloadSuccess\": \"配置下载成功\",\n        \"downloadFailed\": \"配置下载失败\",\n        \"autoSync\": \"上传/下载时会自动同步配置（排除工作区路径等设备特定配置）\"\n      }\n    },\n    \"imageHosting\": {\n      \"title\": \"图床设置\",\n      \"desc\": \"在这里，你可以配置图床服务，用于存储和管理你的图片。\",\n      \"type\": \"选择平台\",\n      \"typeDesc\": \"选择图床服务提供商\",\n      \"customRepoName\": \"自定义仓库名\",\n      \"customRepoNameDesc\": \"留空则使用默认仓库名\",\n      \"isPrimaryBackup\": \"当前 {type} 为主要图床\",\n      \"setPrimaryBackup\": \"设为主要图床\",\n      \"smms\": {\n        \"token\": {\n          \"desc\": \"请创建并输入 SM.MS Token。\",\n          \"createToken\": \"创建 Token\"\n        },\n        \"disk\": \"磁盘使用情况\",\n        \"error\": \"获取失败，请检查网络或 Token 是否正确。\"\n      },\n      \"picgo\": {\n        \"desc\": \"PicGo 服务器地址\",\n        \"ok\": \"检测到服务正在运行，请确保 PicGo 图床已配置。\",\n        \"error\": \"服务未运行，请确保 PicGo（需要 v2.2.0+） 应用正在运行，否则无法上传图片。\"\n      },\n      \"github\": {\n        \"title\": \"GitHub 图床\",\n        \"description\": \"使用 GitHub 仓库作为图片存储服务\",\n        \"repoStatus\": \"仓库状态\",\n        \"repoExists\": \"仓库已存在\",\n        \"repoNotExists\": \"仓库不存在\",\n        \"checking\": \"检测中\",\n        \"creating\": \"创建中\",\n        \"manualCreateTitle\": \"需要手动创建图床仓库\",\n        \"manualCreateDesc\": \"请按以下步骤创建图床仓库：\",\n        \"createSteps\": {\n          \"step1\": \"访问 GitHub 并登录您的账户\",\n          \"step2\": \"点击右上角的 \\\"+\\\" 按钮，选择 \\\"New repository\\\"\",\n          \"step3\": \"仓库名称设置为：\",\n          \"step4\": \"可以选择设置为私有仓库（推荐）\",\n          \"step5\": \"点击 \\\"Create repository\\\" 完成创建\",\n          \"step6\": \"创建完成后，点击下方的\\\"重新检测\\\"按钮\"\n        },\n        \"createNewRepo\": \"创建新仓库\",\n        \"recheckRepo\": \"重新检测\",\n        \"recheckingRepo\": \"检测中...\"\n      },\n      \"s3\": {\n        \"title\": \"S3 对象存储\",\n        \"description\": \"配置 AWS S3 或兼容 S3 协议的对象存储服务作为图床\",\n        \"status\": \"连接状态\",\n        \"connected\": \"已连接\",\n        \"connecting\": \"连接中\",\n        \"disconnected\": \"未连接\",\n        \"accessKeyId\": \"Access Key ID\",\n        \"accessKeyIdPlaceholder\": \"输入 Access Key ID\",\n        \"secretAccessKey\": \"Secret Access Key\",\n        \"secretAccessKeyPlaceholder\": \"输入 Secret Access Key\",\n        \"region\": \"区域\",\n        \"bucket\": \"存储桶\",\n        \"bucketPlaceholder\": \"输入存储桶名称\",\n        \"advancedSettings\": \"高级设置\",\n        \"endpoint\": \"自定义端点\",\n        \"endpointDesc\": \"留空使用 AWS S3，或输入兼容 S3 的服务端点\",\n        \"customDomain\": \"自定义域名\",\n        \"customDomainDesc\": \"可选，用于访问图片的自定义域名\",\n        \"pathPrefix\": \"路径前缀\",\n        \"pathPrefixDesc\": \"可选，图片存储的路径前缀\",\n        \"save\": \"保存配置\",\n        \"test\": \"测试连接\",\n        \"setAsPrimary\": \"设为主要图床\",\n        \"error\": \"配置错误\",\n        \"requiredFields\": \"请填写必填字段：Access Key ID、Secret Access Key、区域和存储桶\",\n        \"saveSuccess\": \"配置保存成功\",\n        \"saveSuccessDesc\": \"S3 配置已保存\",\n        \"saveError\": \"配置保存失败\",\n        \"testSuccess\": \"连接测试成功\",\n        \"testSuccessDesc\": \"S3 连接正常，可以上传图片\",\n        \"testFailed\": \"连接测试失败\",\n        \"testFailedDesc\": \"请检查配置信息和网络连接\",\n        \"testFirstDesc\": \"请先测试连接成功后再设为主要图床\",\n        \"setPrimarySuccess\": \"设置成功\",\n        \"setPrimarySuccessDesc\": \"S3 已设为主要图床\"\n      }\n    },\n    \"backupSync\": {\n      \"title\": \"备用方案\",\n      \"desc\": \"在这里，你可以使用其他方案来备份你的数据，你可以定期进行备份，以确保数据的安全。\",\n      \"localBackup\": {\n        \"tabTitle\": \"本地备份\",\n        \"export\": {\n          \"title\": \"导出备份\",\n          \"desc\": \"将应用数据打包为 .zip 文件，保存到指定位置。\",\n          \"button\": \"选择位置并导出\",\n          \"simpleButton\": \"导出\",\n          \"exporting\": \"导出中...\"\n        },\n        \"import\": {\n          \"title\": \"导入备份\",\n          \"desc\": \"从 .zip 文件恢复应用数据，将覆盖当前所有数据。\",\n          \"button\": \"选择文件并导入\",\n          \"importing\": \"导入中...\",\n          \"warning\": \"导入操作将覆盖所有当前数据，请确保已备份重要内容！\"\n        },\n        \"exportDialog\": {\n          \"title\": \"选择备份文件保存位置\"\n        },\n        \"importDialog\": {\n          \"title\": \"选择要导入的备份文件\"\n        },\n        \"exportSuccess\": \"备份导出成功！\",\n        \"exportError\": \"备份导出失败\",\n        \"importSuccess\": \"备份导入成功！应用将重启以应用更改。\",\n        \"importError\": \"备份导入失败\",\n        \"restartConfirm\": \"导入完成！是否立即重启应用以应用更改？\"\n      }\n    },\n    \"template\": {\n      \"title\": \"整理模板\",\n      \"desc\": \"在这里，你可以创建和管理自定义整理模板，帮助 AI 按照你的需求整理记录内容。\",\n      \"customTemplate\": \"自定义模板\",\n      \"addTemplate\": \"新增模板\",\n      \"deleteConfirm\": \"确认删除模板吗?\",\n      \"status\": \"状态\",\n      \"name\": \"名称\",\n      \"content\": \"内容\",\n      \"scope\": \"范围\",\n      \"selectScope\": \"选择范围\",\n      \"addTemplateDesc\": \"请输入自定义模板名称和内容，帮助 AI 更好地理解你的需求。\",\n      \"editTemplate\": \"编辑自定义模板\",\n      \"noContent\": \"暂无内容\",\n      \"range\": {\n        \"all\": \"全部\",\n        \"today\": \"今天\",\n        \"week\": \"近一周\",\n        \"month\": \"近一月\",\n        \"threeMonth\": \"近三个月\",\n        \"year\": \"近一年\"\n      }\n    },\n    \"shortcut\": {\n      \"title\": \"快捷键\",\n      \"screenshot\": \"截图记录\",\n      \"link\": \"链接记录\",\n      \"textRecord\": \"文本记录\",\n      \"windowPin\": \"窗口置顶\"\n    },\n    \"theme\": {\n      \"title\": \"外观\",\n      \"appTheme\": \"应用配色\",\n      \"previewTheme\": \"预览内容主题\",\n      \"codeTheme\": \"代码块高亮主题\",\n      \"selectTheme\": \"选择主题\"\n    },\n    \"dev\": {\n      \"title\": \"开发者\",\n      \"desc\": \"在这里，你可以配置开发者选项，包括网络代理、数据清理和配置文件管理等高级功能。\",\n      \"clearData\": \"清理数据\",\n      \"clearDataConfirm\": \"确定清理数据吗？\",\n      \"proxy\": \"代理，用于解决网络问题，配置后建议重启应用。\",\n      \"proxyPlaceholder\": \"请输入代理地址\",\n      \"proxyTitle\": \"网络代理\",\n      \"clearDataTitle\": \"清理数据\",\n      \"clearDataDesc\": \"清理数据信息，包括系统配置信息、数据库（包含记录）。\",\n      \"clearFileTitle\": \"清理文件\",\n      \"clearFileDesc\": \"清理文件，包括图片、文章。\",\n      \"clearButton\": \"清理\",\n      \"configFileTitle\": \"配置文件管理\",\n      \"configFileDesc\": \"配置文件导入与导出，导入配置文件将覆盖当前配置，并且重启后生效。\",\n      \"importConfigTitle\": \"导入配置文件\",\n      \"exportConfigTitle\": \"导出配置文件\",\n      \"importConfigSuccessMobile\": \"配置下载成功，请手动重启应用\",\n      \"exportConfigSuccess\": \"导出成功\",\n      \"importButton\": \"导入\",\n      \"exportButton\": \"导出\"\n    },\n    \"chat\": {\n      \"title\": \"对话设置\",\n      \"desc\": \"在这里，你可以配置对话相关的设置，包括摘要生成等功能。\",\n      \"primaryModel\": {\n        \"title\": \"主要模型\",\n        \"model\": {\n          \"title\": \"主要聊天模型\",\n          \"desc\": \"选择用于日常对话的主要 AI 模型\"\n        }\n      },\n      \"toolbar\": {\n        \"title\": \"工具栏设置\",\n        \"chatToolbar\": {\n          \"title\": \"对话工具栏\",\n          \"desc\": \"自定义对话工具栏按钮的显示顺序和可见性\",\n          \"button\": \"设置\",\n          \"modelSelect\": {\n            \"desc\": \"切换用于对话的 AI 模型\"\n          },\n          \"promptSelect\": {\n            \"desc\": \"选择对话使用的预设提示词\"\n          },\n          \"chatLanguage\": {\n            \"desc\": \"设置对话的语言\"\n          },\n          \"chatLink\": {\n            \"title\": \"关联标签\",\n            \"desc\": \"关联当前标签的笔记内容到对话上下文\"\n          },\n          \"fileLink\": {\n            \"desc\": \"关联文件或文件夹到对话上下文\"\n          },\n          \"mcpButton\": {\n            \"desc\": \"选择并连接 MCP 服务器以使用外部工具\"\n          },\n          \"ragSwitch\": {\n            \"title\": \"知识库检索\",\n            \"desc\": \"启用向量知识库检索功能\"\n          },\n          \"clipboardMonitor\": {\n            \"title\": \"剪贴板监听\",\n            \"desc\": \"自动监听剪贴板内容变化\"\n          },\n          \"newChat\": {\n            \"desc\": \"开始新对话\"\n          },\n          \"clearContext\": {\n            \"desc\": \"清除对话上下文，保留聊天记录\"\n          },\n          \"clearChat\": {\n            \"desc\": \"删除所有聊天记录\"\n          }\n        }\n      },\n      \"condense\": {\n        \"title\": \"对话摘要\",\n        \"enable\": {\n          \"title\": \"启用摘要\",\n          \"desc\": \"自动压缩长对话以节省 token 使用量\"\n        },\n        \"model\": {\n          \"title\": \"摘要模型\",\n          \"desc\": \"选择用于生成摘要的 AI 模型\",\n          \"placeholder\": \"使用主模型\"\n        },\n        \"threshold\": {\n          \"title\": \"触发阈值\",\n          \"desc\": \"AI 消息超过此数量时检查压缩\"\n        },\n        \"minToken\": {\n          \"title\": \"最小 Token 数\",\n          \"desc\": \"单条消息超过此 token 数才进行压缩\"\n        },\n        \"keepLatest\": {\n          \"title\": \"保留最新条数\",\n          \"desc\": \"保留最新的 N 条 AI 消息不进行压缩\"\n        },\n        \"maxLength\": {\n          \"title\": \"摘要长度限制\",\n          \"desc\": \"控制生成摘要的最大字数\"\n        },\n        \"prompt\": {\n          \"title\": \"自定义摘要提示词\",\n          \"desc\": \"自定义生成摘要时使用的提示词模板\",\n          \"label\": \"提示词模板\",\n          \"placeholder\": \"输入自定义提示词...\",\n          \"help\": \"使用 {content} 作为原始内容的占位符\",\n          \"save\": \"保存\",\n          \"reset\": \"重置为默认\"\n        }\n      },\n      \"conversationTitle\": {\n        \"title\": \"会话标题\",\n        \"model\": {\n          \"title\": \"标题生成模型\",\n          \"desc\": \"选择用于生成会话标题的 AI 模型\"\n        }\n      },\n      \"inspiration\": {\n        \"title\": \"灵感模型\",\n        \"model\": {\n          \"title\": \"灵感生成模型\",\n          \"desc\": \"用于生成快速提示词建议，帮助用户快速开始对话\"\n        }\n      }\n    },\n    \"ai\": {\n      \"title\": \"模型配置\",\n      \"desc\": \"在这里，你可以添加和管理各种自定义模型提供服务，配置后将解锁 AI 相关功能，例如整理和对话功能。\",\n      \"modelTitle\": \"自定义名称\",\n      \"modelConfigTitle\": \"模型配置\",\n      \"modelConfigDesc\": \"每一个配置对应一个 AI 模型，你可以通过模板或自定义创建新的配置。\",\n      \"providerInfo\": \"供应商信息\",\n      \"providerInfoDesc\": \"此配置基于供应商模板创建，名称和地址已预设，无需修改。\",\n      \"create\": \"创建新配置\",\n      \"createDesc\": \"选择空配置或使用供应商模板创建新的配置。\",\n      \"createSection\": {\n        \"title\": \"自定义模型配置\",\n        \"descWithoutModels\": \"添加自定义 AI 模型配置来使用更强大的模型服务。\"\n      },\n      \"config\": \"配置单\",\n      \"custom\": \"自定义模型配置\",\n      \"addCustomModel\": \"自定义\",\n      \"deleteCustomModel\": \"删除\",\n      \"deleteCustomModelConfirm\": \"确认删除此自定义模型配置吗?\",\n      \"copyConfig\": \"复制\",\n      \"builtin\": \"内置\",\n      \"modelSupport\": \"仅支持 openai 协议的 AI 模型\",\n      \"apiKeyUrl\": \"创建 API Key\",\n      \"modelType\": {\n        \"title\": \"模型类型\",\n        \"desc\": \"根据 AI 模型能力选择模型类型，用以调用不同的接口。\",\n        \"chat\": \"对话\",\n        \"image\": \"生图\",\n        \"video\": \"视频\",\n        \"tts\": \"文本转语音\",\n        \"stt\": \"语音转文本\",\n        \"embedding\": \"嵌入\",\n        \"rerank\": \"重排序\"\n      },\n      \"modelList\": {\n        \"error\": {\n          \"title\": \"获取模型列表失败\",\n          \"description\": \"请检查 API Key 或网络是否正确\"\n        }\n      },\n      \"selectModel\": \"请选择模型\",\n      \"modelProviderDesc\": \"自定义模型仅支持 openai 协议的 AI 模型。\",\n      \"modelTitleDesc\": \"自定义名称，用于标识 AI 模型，请勿重复。\",\n      \"modelBaseUrlDesc\": \"你只需要配置到版本号即可，例如：https://api.openai.com/v1，后缀会自动添加。\",\n      \"modelDesc\": \"部分模型支持获取模型列表，如果不支持请手动配置。\",\n      \"temperatureDesc\": \"使用什么采样温度，介于 0 和 2 之间。较高的值（如 0.8）将使输出更加随机，而较低的值（如 0.2）将使输出更加集中和确定。 我们通常建议改变这个或top_p但不是两者。\",\n      \"topPDesc\": \"一种替代温度采样的方法，称为核采样，其中模型考虑具有 top_p 概率质量的标记的结果。所以 0.1 意味着只考虑构成前 10% 概率质量的标记。 我们通常建议改变这个或temperature但不是两者。\",\n      \"customHeaders\": \"自定义请求头\",\n      \"customHeadersDesc\": \"添加自定义 HTTP 请求头，支持多个键值对配置。\",\n      \"headerKey\": \"键\",\n      \"headerValue\": \"值\",\n      \"addHeader\": \"添加 Header\",\n      \"connectionSuccess\": \"AI 连接测试通过\",\n      \"connectionFailed\": \"连接失败\",\n      \"voice\": \"语音类型\",\n      \"voiceDesc\": \"指定音频模型使用的语音类型，如 'alloy'、'echo'、'fable' 等。\",\n      \"voicePlaceholder\": \"请输入语音类型，如：alloy\",\n      \"enableStream\": \"流式响应\",\n      \"enableStreamDesc\": \"启用流式响应可以实时显示生成内容，但某些模型可能不支持此功能。\",\n      \"selectConfig\": \"请选择配置\",\n      \"models\": \"模型列表\",\n      \"modelsDesc\": \"在这里管理当前配置下的所有模型，每个模型可以有不同的类型和参数。\",\n      \"addModel\": \"添加模型\",\n      \"newModel\": \"新模型\",\n      \"checkConnection\": \"检测连接\",\n      \"model\": \"模型\",\n      \"defaultModels\": {\n        \"title\": \"默认免费模型\",\n        \"desc\": \"NoteGen 为用户提供免费的 AI 模型服务，由硅基流动提供支持，无需配置即可使用基础功能。\",\n        \"chatModel\": {\n          \"name\": \"Qwen/Qwen3-8B\",\n          \"type\": \"对话模型\",\n          \"desc\": \"适用于日常对话、文本生成等场景\"\n        },\n        \"embeddingModel\": {\n          \"name\": \"BAAI/bge-m3\",\n          \"type\": \"嵌入模型\",\n          \"desc\": \"用于文本向量化、语义搜索等功能\"\n        },\n        \"visionModel\": {\n          \"name\": \"OpenGVLab/InternVL2-8B\",\n          \"type\": \"视觉模型\",\n          \"desc\": \"支持图像理解、视觉问答等功能\"\n        },\n        \"completionModel\": {\n          \"name\": \"快速补全\",\n          \"type\": \"补全模型\",\n          \"desc\": \"用于 Markdown 编辑器的 AI 内联补全，类似 GitHub Copilot，快速生成续写内容\"\n        },\n        \"poweredBy\": \"由 SiliconFlow 提供技术支持\"\n      }\n    },\n    \"imageMethod\": {\n      \"title\": \"图像识别\",\n      \"desc\": \"在这里，你可以配置图像识别相关设置，支持 OCR 和 VLM 两种方式。\",\n      \"enable\": {\n        \"title\": \"启用图像识别\",\n        \"desc\": \"开启后，在截图记录和插图记录时会自动进行图片识别。关闭后将跳过图片识别步骤。\"\n      },\n      \"setPrimary\": \"设置为默认\",\n      \"isPrimary\": \"{type} 已设置为默认\",\n      \"ocr\": {\n        \"title\": \"OCR\",\n        \"languagePacks\": \"语言包\",\n        \"checkModels\": \"在此查询全部模型\",\n        \"modelInstruction\": \"以逗号分隔，例如：eng,chi_sim\"\n      },\n      \"vlm\": {\n        \"title\": \"视觉语言模型\",\n        \"desc\": \"通过视觉语言模型识别图片内容。\"\n      }\n    },\n    \"file\": {\n      \"title\": \"文件管理\",\n      \"desc\": \"在这里，你可以管理工作区设置和其他文件相关选项。\",\n      \"workspace\": {\n        \"title\": \"工作区设置\",\n        \"desc\": \"设置应用程序的工作区目录，文件将保存在该目录中。\",\n        \"current\": \"工作区路径\",\n        \"defaultPath\": \"默认工作区\",\n        \"default\": \"当前使用默认工作区路径。\",\n        \"custom\": \"当前使用自定义工作区路径。\",\n        \"select\": \"选择工作区目录\",\n        \"reset\": \"重置为默认路径\",\n        \"history\": \"历史路径\",\n        \"selectFromHistory\": \"从历史记录中选择工作区\",\n        \"clearHistory\": \"清空历史记录\",\n        \"actions\": \"操作\",\n        \"searchPlaceholder\": \"搜索工作区路径...\",\n        \"noResults\": \"未找到结果\"\n      },\n      \"info\": {\n        \"title\": \"工作区说明\",\n        \"desc\": \"更改工作区后需要重启应用程序才能完全生效。新工作区中的文件将在重启后显示。\"\n      },\n      \"toast\": {\n        \"updated\": \"工作区已更新\",\n        \"updatedDesc\": \"工作区已设置为: {path}\",\n        \"reset\": \"工作区已重置\",\n        \"resetDesc\": \"已恢复使用默认工作区\",\n        \"error\": \"选择工作区失败\",\n        \"errorDesc\": \"无法选择工作区目录，请重试\",\n        \"resetError\": \"重置工作区失败\",\n        \"resetErrorDesc\": \"无法重置为默认工作区，请重试\"\n      },\n      \"assets\": {\n        \"title\": \"写作资源路径\",\n        \"desc\": \"设置写作资源的保存路径，例如图片、视频、文件等，与当前编辑的 markdown 文件同级。\",\n        \"select\": \"请设置写作资源路径，例如：assets\"\n      }\n    },\n    \"shortcuts\": {\n      \"title\": \"快捷键\",\n      \"desc\": \"在这里，你可以配置快捷键，帮助你更高效地使用 NoteGen。\",\n      \"resetDefaults\": \"重置\",\n      \"clear\": \"清空\",\n      \"noShortcut\": \"未设置\",\n      \"shortcuts\": {\n        \"openWindow\": {\n          \"title\": \"打开/隐藏窗口\",\n          \"desc\": \"打开/隐藏程序主窗口。\"\n        },\n        \"quickRecordText\": {\n          \"title\": \"快速记录文本\",\n          \"desc\": \"快速打开程序主窗口，并调出文本记录。\"\n        }\n      }\n    }\n  },\n  \"record\": {\n    \"trash\": {\n      \"title\": \"清空回收站\",\n      \"confirm\": \"确定清空回收站吗？\",\n      \"records\": \"共 {count} 条记录可还原\",\n      \"empty\": \"清空\",\n      \"restoreAll\": \"全部还原\",\n      \"close\": \"关闭回收站\"\n    },\n    \"queue\": {\n      \"ocr\": \"OCR 识别\",\n      \"ai\": \"AI 内容识别\",\n      \"upload\": \"上传至图床\",\n      \"jsdelivr\": \"通知 jsdelivr 缓存\",\n      \"save\": \"保存\",\n      \"recording\": \"记录中...\",\n      \"recorded\": \"已记录\",\n      \"record\": \"记录\",\n      \"detected\": \"检测到\"\n    },\n    \"mark\": {\n      \"empty\": \"暂无记录\",\n      \"loading\": \"加载中...\",\n      \"type\": {\n        \"scan\": \"截图\",\n        \"image\": \"插图\",\n        \"screenshot\": \"截图\",\n        \"text\": \"文本\",\n        \"recording\": \"录音\",\n        \"file\": \"文件\",\n        \"link\": \"链接\",\n        \"todo\": \"待办\",\n        \"pdf\": \"PDF\",\n        \"upload\": \"上传记录\",\n        \"download\": \"下载记录\",\n        \"uploadTo\": \"从本地同步到 {provider}\",\n        \"downloadFrom\": \"从 {provider} 同步到本地\"\n      },\n      \"note\": {\n        \"organizeAs\": \"整理为\",\n        \"template\": \"模板\",\n        \"setting\": \"设置\",\n        \"confirm\": \"确认\",\n        \"cancel\": \"取消\",\n        \"removeThinking\": \"移除思考过程\",\n        \"stop\": \"停止\"\n      },\n      \"uploadSuccess\": \"记录上传成功\",\n      \"downloadSuccess\": \"记录下载成功\",\n      \"desc\": \"描述\",\n      \"content\": \"内容\",\n      \"createdAt\": \"创建于\",\n      \"progress\": {\n        \"cacheImage\": \"缓存图片\",\n        \"ocr\": \"OCR 识别\",\n        \"aiAnalysis\": \"AI 内容识别\",\n        \"uploadImage\": \"上传至图床\",\n        \"jsdelivrCache\": \"通知 jsdelivr 缓存\",\n        \"cacheFile\": \"缓存文件\",\n        \"cacheScreenshot\": \"缓存截图\",\n        \"textAnalysis\": \"文本分析\",\n        \"save\": \"保存\",\n        \"saveImage\": \"保存图片\"\n      },\n      \"imageGallery\": {\n        \"expand\": \"展开\",\n        \"collapse\": \"收起\"\n      },\n      \"text\": {\n        \"title\": \"记录文本\",\n        \"description\": \"记录一段文本，笔记整理时将插入到合适的位置。\",\n        \"characterCount\": \"{count} 字符\",\n        \"save\": \"记录\",\n        \"autoReadClipboard\": \"自动读取剪贴板文本\"\n      },\n      \"link\": {\n        \"title\": \"链接记录\",\n        \"description\": \"输入网页链接，系统将自动爬取页面内容并保存\",\n        \"save\": \"保存\",\n        \"autoReadClipboard\": \"自动读取剪贴板链接\"\n      },\n      \"todo\": {\n        \"title\": \"待办记录\",\n        \"description\": \"创建待办事项，管理你的任务\",\n        \"titlePlaceholder\": \"输入待办标题...\",\n        \"descriptionPlaceholder\": \"输入详细描述（可选）\",\n        \"priority\": \"优先级\",\n        \"priorityLow\": \"低\",\n        \"priorityMedium\": \"中\",\n        \"priorityHigh\": \"高\",\n        \"dateRange\": \"日期范围\",\n        \"dateRangePlaceholder\": \"选择日期范围\",\n        \"dueDate\": \"截止日期\",\n        \"dueDatePlaceholder\": \"选择日期\",\n        \"save\": \"创建待办\",\n        \"saveEdit\": \"保存\",\n        \"edit\": \"编辑待办\",\n        \"editDescription\": \"修改待办事项的详细信息\",\n        \"cancel\": \"取消\",\n        \"selectTag\": \"选择标签\",\n        \"completed\": \"已完成\",\n        \"uncompleted\": \"未完成\"\n      },\n      \"clipboard\": {\n        \"detectedImage\": \"检测到剪贴板图片\",\n        \"detectedText\": \"检测到剪贴板文本\"\n      },\n      \"tag\": {\n        \"searchPlaceholder\": \"创建或查询标签...\",\n        \"noResults\": \"未查询到相关标签\",\n        \"quickAdd\": \"快速创建\",\n        \"pinned\": \"置顶\",\n        \"others\": \"其他\",\n        \"rename\": \"重命名\",\n        \"delete\": \"删除\",\n        \"pin\": \"置顶\",\n        \"unpin\": \"取消置顶\",\n        \"newTag\": \"新建标签\",\n        \"newTagPlaceholder\": \"输入标签名称...\",\n        \"add\": \"添加\"\n      },\n      \"mark\": {\n        \"empty\": \"暂无记录\",\n        \"emptyHint\": \"使用顶部工具栏开始你的第一条记录吧！\",\n        \"type\": {\n          \"text\": \"文本\"\n        },\n        \"chat\": {\n          \"modeSelect\": {\n            \"chat\": \"对话\",\n            \"agent\": \"智能体\"\n          },\n          \"agent\": {\n            \"running\": \"Agent 运行中\",\n            \"thinking\": \"思考中\",\n            \"acting\": \"执行中\",\n            \"observation\": \"观察结果\",\n            \"thought\": \"思考\",\n            \"action\": \"行动\",\n            \"toolCalls\": \"工具调用\",\n            \"confirmation\": {\n              \"title\": \"确认操作\",\n              \"description\": \"Agent 想要执行以下操作，请确认后继续。\",\n              \"tool\": \"工具\",\n              \"parameters\": \"参数\",\n              \"confirm\": \"确认\",\n              \"cancel\": \"取消\",\n              \"confirmed\": \"已确认\",\n              \"cancelled\": \"已取消\"\n            }\n          },\n          \"placeholder\": {\n            \"default\": \"你可以提问或将记录整理为文章...\",\n            \"noApiKey\": \"未配置 API Key，无法使用 AI 对话功能...\",\n            \"on\": \"AI建议开启\",\n            \"off\": \"AI建议关闭\"\n          },\n          \"header\": {\n            \"configApiKey\": \"配置 API KEY\",\n            \"clearChat\": \"清空对话\",\n            \"configPrompt\": \"配置 Prompt\",\n            \"selectPrompt\": \"选择 Prompt\"\n          },\n          \"clipboard\": {\n            \"image\": {\n              \"detected\": \"检测到剪贴板存在图片：\",\n              \"recording\": \"正在记录\",\n              \"recorded\": \"已记录\",\n              \"record\": \"记录\"\n            },\n            \"text\": {\n              \"detected\": \"检测到剪贴板存在文本：\",\n              \"recorded\": \"已记录\",\n              \"record\": \"记录\"\n            }\n          },\n          \"messageControl\": {\n            \"words\": \"字\",\n            \"summary\": \"摘要\"\n          },\n          \"mcp\": {\n            \"maxIterationsReached\": \"达到最大工具调用次数限制\",\n            \"toolCall\": \"MCP 服务器\",\n            \"params\": \"参数\",\n            \"result\": \"返回结果\",\n            \"copy\": \"复制\",\n            \"paramsCopied\": \"参数已复制\",\n            \"resultCopied\": \"结果已复制\",\n            \"calling\": \"调用中\",\n            \"success\": \"已完成\",\n            \"error\": \"失败\"\n          },\n          \"empty\": {\n            \"title\": \"开始与 AI 对话\",\n            \"subtitle\": \"使用 Chat 或 Agent 模式与 AI 互动\",\n            \"currentModel\": \"当前模型\",\n            \"currentPrompt\": \"当前 Prompt\",\n            \"noModel\": \"未设置模型\",\n            \"noPrompt\": \"未设置 Prompt\",\n            \"modeHint\": \"点击输入框左侧的\",\n            \"modeHintSuffix\": \"按钮可切换对话模式\"\n          },\n          \"content\": {\n            \"organize\": \"将你的记录整理为文章：\"\n          },\n          \"note\": {\n            \"writing\": \"写作\",\n            \"convert\": \"转化文章\",\n            \"description\": \"当前的笔记是由 AI 生成且无法编辑，将当前笔记转化为文章（生成本地文件），可在写作页面中进行二次创作。\",\n            \"filename\": \"文件名\",\n            \"selectFolder\": \"选择文件夹\",\n            \"rootDirectory\": \"根目录\",\n            \"deleteTag\": \"删除当前标签、记录和笔记（回收站可恢复）\",\n            \"warning\": \"转换后将跳转到写作页面。\",\n            \"convert_button\": \"转化\"\n          },\n          \"mark\": {\n            \"recorded\": \"已记录\",\n            \"record\": \"记录\"\n          },\n          \"send\": \"发送\"\n        },\n        \"text\": {\n          \"title\": \"记录文本\",\n          \"description\": \"记录一段文本，笔记整理时将插入到合适的位置。\",\n          \"characterCount\": \"{count} 字符\",\n          \"save\": \"记录\"\n        },\n        \"clipboard\": {\n          \"detectedImage\": \"检测到剪贴板图片\",\n          \"detectedText\": \"检测到剪贴板文本\"\n        },\n        \"tag\": {\n          \"searchPlaceholder\": \"创建或查询标签...\",\n          \"noResults\": \"未查询到相关标签\",\n          \"quickAdd\": \"快速创建\",\n          \"pinned\": \"置顶\",\n          \"others\": \"其他\",\n          \"rename\": \"重命名\",\n          \"delete\": \"删除\",\n          \"pin\": \"置顶\",\n          \"unpin\": \"取消置顶\"\n        },\n        \"progress\": {\n          \"cacheImage\": \"缓存图片\",\n          \"ocr\": \"OCR 识别\",\n          \"aiAnalysis\": \"AI 内容识别\",\n          \"uploadImage\": \"上传至图床\",\n          \"jsdelivrCache\": \"通知 jsdelivr 缓存\",\n          \"cacheFile\": \"缓存文件\",\n          \"cacheScreenshot\": \"缓存截图\",\n          \"textAnalysis\": \"文本分析\",\n          \"save\": \"保存\",\n          \"saveImage\": \"保存图片\"\n        }\n      },\n      \"toolbar\": {\n        \"search\": \"搜索\",\n        \"filter\": {\n          \"title\": \"筛选\",\n          \"description\": \"按内容、时间和类型即时过滤记录。\",\n          \"search\": \"搜索\",\n          \"searchPlaceholder\": \"搜索记录内容、描述或链接\",\n          \"type\": \"类型\",\n          \"time\": \"时间\",\n          \"tag\": \"标签\",\n          \"allTags\": \"全部标签\",\n          \"clear\": \"清空筛选\",\n          \"selectAllTypes\": \"选择全部类型\",\n          \"clearTypes\": \"清空类型选择\",\n          \"timeOptions\": {\n            \"all\": \"全部时间\",\n            \"today\": \"今天\",\n            \"last7Days\": \"最近7天\",\n            \"last30Days\": \"最近30天\"\n          }\n        },\n        \"trash\": \"回收站\",\n        \"closeTrash\": \"关闭回收站\",\n        \"organizeNotes\": \"整理笔记\",\n        \"organizeSuccess\": \"笔记整理成功：{title}\",\n        \"organizeError\": \"整理笔记失败\",\n        \"currentTag\": \"当前标签\",\n        \"restore\": \"还原\",\n        \"delete\": \"删除\",\n        \"deleteConfirm\": \"确定要删除吗？\",\n        \"moveTag\": \"转移标签\",\n        \"convertTo\": \"转换为{type}\",\n        \"copyLink\": \"复制链接\",\n        \"copied\": \"已复制到剪切板！\",\n        \"regenerateDesc\": \"重新生成描述\",\n        \"viewFolder\": \"查看目录\",\n        \"viewFile\": \"查看原文件\",\n        \"deleteForever\": \"彻底删除\",\n        \"multiSelect\": \"多选\",\n        \"exitMultiSelect\": \"退出多选\",\n        \"selectAll\": \"全选\",\n        \"deselectAll\": \"取消全选\",\n        \"selectedCount\": \"已选择 {count} 项\",\n        \"visibleCount\": \"共 {count} 条记录\",\n        \"moveSelectedTags\": \"转移选中的 {count} 项\",\n        \"deleteSelected\": \"删除选中的 {count} 项\",\n        \"deleteSelectedForever\": \"彻底删除选中的 {count} 项\",\n        \"view\": {\n          \"list\": \"列表视图\",\n          \"compact\": \"紧凑视图\",\n          \"cards\": \"卡片视图\"\n        },\n        \"text\": \"记录文本\",\n        \"recording\": \"录音记录\",\n        \"scan\": \"扫描图片\",\n        \"image\": \"上传图片\",\n        \"link\": \"记录链接\",\n        \"file\": \"上传文件\",\n        \"todo\": \"待办记录\"\n      },\n      \"list\": {\n        \"title\": \"记录\",\n        \"emptyFiltered\": \"没有符合条件的记录\",\n        \"emptyFilteredHint\": \"试试调整搜索词或筛选条件\",\n        \"filteredLabel\": \"已筛选 {count} 条\",\n        \"filtered\": \"已筛选\",\n        \"filteredByTag\": \"标签\",\n        \"filteredByType\": \"{count} 种类型\",\n        \"filteredSummary\": \"当前显示 {count} 条结果 · {filters}\",\n        \"searchChip\": \"搜索: {value}\",\n        \"time\": {\n          \"today\": \"今天\",\n          \"last7Days\": \"最近7天\",\n          \"last30Days\": \"最近30天\"\n        }\n      }\n    },\n    \"chat\": {\n      \"condensing\": \"正在压缩上下文...\",\n      \"condensed\": {\n        \"message\": \"已压缩 {count} 条历史消息\"\n      },\n      \"empty\": {\n        \"title\": \"开始与 AI 对话\",\n        \"subtitle\": \"使用 Chat 或 Agent 模式与 AI 互动\",\n        \"currentModel\": \"当前模型\",\n        \"currentPrompt\": \"当前 Prompt\",\n        \"currentMode\": \"对话模式\",\n        \"noModel\": \"未设置模型\",\n        \"noPrompt\": \"未设置 Prompt\",\n        \"configureModel\": \"配置模型\",\n        \"recentConversations\": \"最近对话\",\n        \"deleteConversation\": \"删除对话\",\n        \"conversationHistory\": \"历史对话\",\n        \"viewMore\": \"查看更多\",\n        \"messages\": \"条消息\",\n        \"searchPlaceholder\": \"搜索对话...\",\n        \"noMatchingConversations\": \"没有找到匹配的对话\",\n        \"noConversationHistory\": \"暂无历史对话\",\n        \"quickPrompts\": {\n          \"title\": \"快速开始\",\n          \"writeNote\": \"帮我写一篇笔记\",\n          \"summarize\": \"帮我总结这段内容\",\n          \"brainstorm\": \"帮我头脑风暴一些想法\",\n          \"explain\": \"帮我解释这个概念\"\n        }\n      },\n      \"newChat\": \"使用新标签开始对话\",\n      \"removeChat\": \"删除当前标签对话\",\n      \"confirmNew\": \"创建新标签\",\n      \"confirmNewDescription\": \"请确认要创建一个新的标签开始对话吗？\",\n      \"confirmRemove\": \"删除标签\",\n      \"confirmRemoveDescription\": \"请注意，删除此标签会连带删除内部的记录，请再次确认。\",\n      \"input\": {\n        \"organize\": \"整理\",\n        \"chat\": \"聊天\",\n        \"placeholder\": {\n          \"default\": \"你可以提问或将记录整理为文章...\",\n          \"noApiKey\": \"未配置 API Key，无法使用 AI 对话功能...\",\n          \"on\": \"AI 建议 (开启)\",\n          \"off\": \"AI 建议 (关闭)\",\n          \"noPrimaryModel\": \"未配置主模型，无法使用 AI 对话功能...\"\n        },\n        \"translate\": {\n          \"tooltip\": \"翻译\",\n          \"translating\": \"翻译中...\",\n          \"showOriginal\": \"显示原文\",\n          \"alreadyTranslated\": \"已翻译为\"\n        },\n        \"clipboardMonitor\": {\n          \"enable\": \"剪贴板监听(开启)\",\n          \"disable\": \"剪贴板监听(关闭)\"\n        },\n        \"send\": \"发送\",\n        \"stop\": \"停止\",\n        \"stopped\": \"对话已终止\",\n        \"terminate\": \"终止\",\n        \"tagLink\": {\n          \"on\": \"已关联标签\",\n          \"off\": \"未关联标签\"\n        },\n        \"mcp\": {\n          \"tooltip\": \"MCP 服务器\"\n        },\n        \"modelSelect\": {\n          \"tooltip\": \"选择 AI 模型\",\n          \"placeholder\": \"搜索 AI 模型\",\n          \"noModel\": \"未找到模型\"\n        },\n        \"promptSelect\": {\n          \"tooltip\": \"选择 Prompt\",\n          \"placeholder\": \"搜索 Prompt\"\n        },\n        \"newChat\": \"开始新对话\",\n        \"chatLanguage\": {\n          \"tooltip\": \"选择对话语言\",\n          \"placeholder\": \"搜索语言\"\n        },\n        \"rag\": {\n          \"notSupported\": \"向量模型不可用\",\n          \"enabled\": \"知识库检索（开启）\",\n          \"disabled\": \"知识库检索（关闭）\"\n        },\n        \"modeSelect\": {\n          \"tooltip\": \"选择输入模式\",\n          \"chat\": \"对话模式\",\n          \"gen\": \"整理模式\",\n          \"translate\": \"翻译模式\"\n        },\n        \"chatModeSelect\": {\n          \"chatDescription\": \"快速对话，分析优先\",\n          \"agentDescription\": \"智能助手，可执行操作\"\n        },\n        \"attachImage\": \"附加图片\",\n        \"imageSelector\": {\n          \"title\": \"选择图片\",\n          \"local\": \"本地文件\",\n          \"records\": \"从记录中选择\",\n          \"selectFiles\": \"选择本地图片\",\n          \"noRecords\": \"没有可用的图片记录\",\n          \"cancel\": \"取消\",\n          \"confirm\": \"确认\"\n        },\n        \"agent\": {\n          \"running\": \"Agent 运行中\",\n          \"thinking\": \"思考中\",\n          \"analyzingRequest\": \"Agent 正在分析您的请求...\",\n          \"acting\": \"执行中\",\n          \"observation\": \"观察结果\",\n          \"thought\": \"思考\",\n          \"action\": \"行动\",\n          \"toolCalls\": \"工具调用\",\n          \"autoFinal\": {\n            \"createNote\": \"已创建笔记《{name}》。\",\n            \"createFile\": \"已创建文件《{name}》。\"\n          },\n          \"confirmation\": {\n            \"title\": \"确认操作\",\n            \"description\": \"Agent 想要执行以下操作，请确认后继续。\",\n            \"tool\": \"工具\",\n            \"parameters\": \"参数\",\n            \"cancel\": \"取消\",\n            \"confirm\": \"确认\",\n            \"confirmed\": \"已确认\",\n            \"cancelled\": \"已取消\",\n            \"fallback\": {\n              \"title\": \"即将执行操作\",\n              \"description\": \"请确认这次操作的目标和内容。\"\n            },\n            \"params\": {\n              \"filePath\": \"文件路径\",\n              \"content\": \"文件内容\",\n              \"sourcePath\": \"来源路径\",\n              \"targetPath\": \"目标路径\",\n              \"files\": \"文件列表\",\n              \"newName\": \"新名称\",\n              \"scriptName\": \"脚本名称\",\n              \"command\": \"命令\"\n            },\n            \"tools\": {\n              \"create_file\": {\n                \"title\": \"创建文件\",\n                \"description\": \"将在工作区中新建一个文件。\"\n              },\n              \"create_files_batch\": {\n                \"title\": \"批量创建文件\",\n                \"description\": \"将在工作区中一次创建多个文件。\"\n              },\n              \"rename_file\": {\n                \"title\": \"重命名文件\",\n                \"description\": \"将修改所选文件的名称。\"\n              },\n              \"move_file\": {\n                \"title\": \"移动文件\",\n                \"description\": \"将把文件移动到新的位置。\"\n              },\n              \"copy_file\": {\n                \"title\": \"复制文件\",\n                \"description\": \"将在目标位置创建该文件的副本。\"\n              },\n              \"replace_editor_content\": {\n                \"title\": \"替换编辑器内容\",\n                \"description\": \"将用新内容替换当前编辑器内容。\"\n              },\n              \"insert_at_cursor\": {\n                \"title\": \"在光标处插入内容\",\n                \"description\": \"会把内容插入到当前光标位置。\"\n              },\n              \"delete_markdown_file\": {\n                \"title\": \"删除文件\",\n                \"description\": \"将永久删除所选文件。\"\n              },\n              \"execute_skill_script\": {\n                \"title\": \"运行脚本\",\n                \"description\": \"将执行由 skill 提供的脚本或命令。\"\n              }\n            }\n          }\n        },\n        \"fileLink\": {\n          \"tooltip\": \"关联文件\",\n          \"selectFile\": \"选择文件\",\n          \"linkedFile\": \"关联文件\",\n          \"searchPlaceholder\": \"搜索文件...\",\n          \"noFiles\": \"未找到文件\",\n          \"loading\": \"加载中...\"\n        }\n      },\n      \"header\": {\n        \"configApiKey\": \"配置 API KEY\",\n        \"clearChat\": \"清空聊天\",\n        \"configPrompt\": \"配置 Prompt\",\n        \"selectPrompt\": \"选择 Prompt\",\n        \"noModel\": \"未选择 AI 模型\"\n      },\n      \"clipboard\": {\n        \"image\": {\n          \"detected\": \"检测到剪贴板存在图片：\",\n          \"recording\": \"正在记录\",\n          \"recorded\": \"已记录\",\n          \"record\": \"记录\"\n        },\n        \"text\": {\n          \"detected\": \"检测到剪贴板存在文本：\",\n          \"recorded\": \"已记录\",\n          \"record\": \"记录\"\n        }\n      },\n      \"messageControl\": {\n        \"words\": \"字\",\n        \"summary\": \"摘要\",\n        \"readAloud\": \"朗读\",\n        \"playing\": \"播放中\",\n        \"loading\": \"准备中\",\n        \"stop\": \"停止播放\",\n        \"copy\": \"复制\",\n        \"copied\": \"已复制\"\n      },\n      \"ragSources\": {\n        \"label\": \"知识库检索到 {count} 篇笔记\",\n        \"openFile\": \"打开文件\"\n      },\n      \"preview\": {\n        \"close\": \"关闭\",\n        \"copy\": \"复制\",\n        \"copied\": \"已复制！\"\n      },\n      \"control\": {\n        \"edit\": \"编辑\",\n        \"save\": \"保存\",\n        \"cancel\": \"取消\",\n        \"delete\": \"删除\",\n        \"deleteConfirm\": \"确定要删除这条消息吗？\"\n      },\n      \"content\": {\n        \"organize\": \"将你的记录整理为文章：\"\n      },\n      \"quote\": {\n        \"lineSingle\": \"引用自 {fileName} 第 {line} 行\",\n        \"lineRange\": \"引用自 {fileName} 第 {startLine}-{endLine} 行\",\n        \"noLine\": \"引用自 {fileName}\"\n      },\n      \"note\": {\n        \"organize\": \"整理\",\n        \"writing\": \"写作\",\n        \"convert\": \"转化文章\",\n        \"description\": \"当前的笔记是由 AI 生成且无法编辑，将当前笔记转化为文章（生成本地文件），可在写作页面中进行二次创作。\",\n        \"filename\": \"文件名\",\n        \"selectFolder\": \"选择文件夹\",\n        \"rootDirectory\": \"根目录\",\n        \"deleteTag\": \"删除当前标签、记录和笔记（回收站可恢复）\",\n        \"warning\": \"转换后将跳转到写作页面。\",\n        \"convert_button\": \"转化\",\n        \"organizeAs\": \"将记录整理成...\",\n        \"templateContent\": \"模板内容\",\n        \"recordRange\": \"记录选择范围\",\n        \"filterThinkingContent\": \"移除记录中的思考\",\n        \"startOrganize\": \"开始整理\",\n        \"manageTemplate\": \"管理模板\",\n        \"cancel\": \"取消\",\n        \"stop\": \"停止\"\n      },\n      \"mark\": {\n        \"recorded\": \"已记录\",\n        \"record\": \"记录\"\n      }\n    },\n    \"tag\": {\n      \"add\": \"添加标签\",\n      \"edit\": \"编辑标签\",\n      \"delete\": \"删除标签\",\n      \"deleteConfirm\": \"确定要删除这个标签吗？\",\n      \"placeholder\": \"输入标签名称\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"搜索笔记和文章...\",\n    \"results\": \"{count} 个搜索结果\",\n    \"noResults\": \"暂无搜索结果\",\n    \"tryDifferentKeywords\": \"尝试使用不同的关键词搜索\",\n    \"item\": {\n      \"record\": \"记录\",\n      \"article\": \"文章\",\n      \"matches\": \"{count}个匹配项\",\n      \"scanType\": \"截图\"\n    }\n  },\n  \"image\": {\n    \"root\": \"图床仓库\",\n    \"noData\": {\n      \"title\": \"同步功能未开启\",\n      \"desc\": \"请先跳转至系统设置页面，配置 Github 同步。\",\n      \"goToSettings\": \"前往设置\",\n      \"howToUse\": \"如何使用同步功能？\"\n    }\n  },\n  \"navigation\": {\n    \"chat\": \"对话\",\n    \"record\": \"记录\",\n    \"quickRecord\": \"快捷记录\",\n    \"write\": \"写作\",\n    \"search\": \"搜索\",\n    \"githubImageHosting\": \"Github 图床\",\n    \"login\": \"登录\",\n    \"loading\": \"载入中\",\n    \"view\": \"查看\",\n    \"logout\": \"登出\",\n    \"setting\": \"设置\",\n    \"activity\": \"活跃度\",\n    \"files\": \"笔记\",\n    \"outline\": \"大纲\",\n    \"showLeftSidebar\": \"显示左侧边栏\",\n    \"hideLeftSidebar\": \"隐藏左侧边栏\",\n    \"showCenterPanel\": \"显示编辑器\",\n    \"hideCenterPanel\": \"隐藏编辑器\",\n    \"showRightSidebar\": \"显示右侧边栏\",\n    \"hideRightSidebar\": \"隐藏右侧边栏\",\n    \"searchPlaceholder\": \"搜索笔记或记录...\"\n  },\n  \"activity\": {\n    \"title\": \"活跃度日历\",\n    \"description\": \"按天查看你的记录、对话和写作活跃情况。第一版基于现有记录、用户对话和笔记修改时间进行统计。\",\n    \"drawer\": {\n      \"title\": \"活跃度\",\n      \"description\": \"快速查看今天状态和最近一段时间的活跃趋势。\",\n      \"today\": \"今天\"\n    },\n    \"loading\": \"正在加载活跃度数据...\",\n    \"empty\": \"暂无活跃度数据\",\n    \"refresh\": \"刷新\",\n    \"summary\": {\n      \"totalCount\": \"总活跃次数\",\n      \"activeDays\": \"活跃天数\",\n      \"records\": \"记录次数\",\n      \"chats\": \"对话次数\",\n      \"writing\": \"写作活跃\"\n    },\n    \"labels\": {\n      \"record\": \"记录\",\n      \"writing\": \"写作\",\n      \"chat\": \"对话\"\n    },\n    \"heatmap\": {\n      \"title\": \"最近 26 周热力图\",\n      \"range\": \"{startDate} - {endDate}\",\n      \"less\": \"少\",\n      \"more\": \"多\",\n      \"dayCount\": \"次活动\",\n      \"emptyDay\": \"无活动\"\n    },\n    \"detail\": {\n      \"title\": \"当天明细\",\n      \"empty\": \"选择一个日期查看当天的活动明细。\"\n    }\n  },\n  \"marks\": {\n    \"types\": {\n      \"screenshot\": \"截图\",\n      \"text\": \"文本\",\n      \"image\": \"插图\"\n    }\n  },\n  \"tags\": {\n    \"inspiration\": \"灵感\"\n  },\n  \"sync\": {\n    \"status\": \"同步仓库状态\",\n    \"imageRepo\": \"图床仓库\",\n    \"articleRepo\": \"文章仓库\"\n  },\n  \"ai\": {\n    \"thinking\": \"思考\",\n    \"error\": {\n      \"title\": \"AI 错误\",\n      \"noAddress\": \"请先设置 AI 地址\"\n    }\n  },\n  \"article\": {\n    \"sync\": {\n      \"syncingRemote\": \"正在拉取远程文件...\",\n      \"syncComplete\": \"同步完成\"\n    },\n    \"syncConfirm\": {\n      \"title\": \"检测到远程文件更新\",\n      \"description\": \"文件 {fileName} 有远程更新\",\n      \"commitInfo\": \"最新提交信息\",\n      \"commitMessage\": \"提交消息\",\n      \"author\": \"作者\",\n      \"changes\": \"变更\",\n      \"confirmMessage\": \"确认要拉取远程版本并覆盖本地文件吗？此操作无法撤销。\",\n      \"cancel\": \"取消\",\n      \"confirmPull\": \"确认拉取\"\n    },\n    \"emptyState\": {\n      \"title\": \"开始创作\",\n      \"subtitle\": \"选择一个文件开始编辑，或创建新的笔记\",\n      \"tip\": \"💡 提示：你也可以从左侧文件管理器中选择文件\",\n      \"actions\": {\n        \"newNote\": {\n          \"title\": \"创建笔记\",\n          \"desc\": \"新建一篇 Markdown 笔记\"\n        },\n        \"newRecord\": {\n          \"title\": \"创建记录\",\n          \"desc\": \"打开文本记录功能\"\n        },\n        \"globalSearch\": {\n          \"title\": \"全局搜索\",\n          \"desc\": \"快速查找你的笔记内容\"\n        },\n        \"openWorkspace\": {\n          \"title\": \"打开工作区\",\n          \"desc\": \"选择或切换工作区目录\"\n        }\n      },\n      \"onboarding\": {\n        \"title\": \"新手引导\",\n        \"subtitle\": \"跟着这三步体验 NoteGen 的核心工作流。\",\n        \"dismiss\": \"跳过新手引导\",\n        \"reopen\": \"重新显示新手引导\",\n        \"start\": \"开始引导\",\n        \"viewHint\": \"查看提示\",\n        \"continue\": \"继续下一步\",\n        \"completed\": \"已完成\",\n        \"allDone\": \"新手任务已全部完成，你已经体验了 NoteGen 的基础工作流。\",\n        \"stepLabel\": \"任务 ({current}/{total})\",\n        \"stepCompletedLabel\": \"已完成任务 ({current}/{total})\",\n        \"afterOrganizeDialog\": {\n          \"title\": \"已完成任务 (2/3)\",\n          \"description\": \"你已经把记录整理成了一篇笔记。要继续体验下一步，用 AI Agent 把这篇笔记翻译成双语版本吗？\",\n          \"confirm\": \"继续下一步\",\n          \"cancel\": \"暂时不用\"\n        },\n        \"agentPrompt\": {\n          \"label\": \"示例提示词\",\n          \"use\": \"使用这条提示词\",\n          \"intro\": \"请将我刚刚整理出的这篇笔记直接修改为中英双语版本。\",\n          \"requirement1\": \"\",\n          \"requirement2\": \"\",\n          \"requirement3\": \"\",\n          \"requirement4\": \"\",\n          \"outro\": \"\"\n        },\n        \"steps\": {\n          \"createRecord\": {\n            \"title\": \"创建第一条记录\",\n            \"desc\": \"先记录一段示例内容，熟悉记录入口。\"\n          },\n          \"organizeNote\": {\n            \"title\": \"整理成笔记\",\n            \"desc\": \"把刚才的记录整理成一篇正式笔记。\"\n          },\n          \"aiPolish\": {\n            \"title\": \"用 Agent 翻译成双语\",\n            \"desc\": \"用 AI Agent 将刚整理好的笔记翻译成双语版本。\"\n          }\n        },\n        \"completedStates\": {\n          \"create-record\": {\n            \"title\": \"你已经创建了第一条记录\",\n            \"desc\": \"现在你已经知道如何快速记录内容了。\"\n          },\n          \"organize-note\": {\n            \"title\": \"你已经把记录整理成笔记\",\n            \"desc\": \"下一步可以体验 AI 如何继续修改这篇笔记。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"你已经用 Agent 处理了笔记\",\n            \"desc\": \"你已经体验了从记录到整理，再到 Agent 处理内容的完整流程。\"\n          }\n        },\n        \"spotlight\": {\n          \"create-record\": {\n            \"title\": \"这里可以快速记录内容\",\n            \"desc\": \"点击这个文本记录入口，系统会自动带入一段示例内容，你也可以自己改。\"\n          },\n          \"organize-note\": {\n            \"title\": \"这里可以整理记录\",\n            \"desc\": \"点击这个按钮，把刚才的记录整理成一篇正式笔记。\"\n          },\n          \"ai-polish\": {\n            \"title\": \"这里可以用 Agent 处理刚生成的笔记\",\n            \"desc\": \"把示例提示词填进对话框后发送，Agent 会基于当前笔记生成双语版本。\"\n          }\n        }\n      }\n    },\n    \"unsupportedFile\": {\n      \"title\": \"无法预览此文件\",\n      \"fileName\": \"文件名\",\n      \"filePath\": \"文件路径\",\n      \"fileSize\": \"文件大小\",\n      \"modifiedTime\": \"修改时间\",\n      \"createdTime\": \"创建时间\",\n      \"pathCopied\": \"路径已复制\",\n      \"openExternal\": \"用外部程序打开\",\n      \"openDirectory\": \"打开文件目录\"\n    },\n    \"file\": {\n      \"toolbar\": {\n        \"accessRepo\": \"访问仓库\",\n        \"loadingSync\": \"正在加载同步信息\",\n        \"configSync\": \"配置同步\",\n        \"newArticle\": \"新建文章\",\n        \"newFolder\": \"新建文件夹\",\n        \"refresh\": \"刷新\",\n        \"toggleFolders\": \"文件夹展开折叠\",\n        \"expandAll\": \"全部展开\",\n        \"collapseAll\": \"全部折叠\",\n        \"sortByName\": \"按名称排序\",\n        \"sortByCreated\": \"按创建时间排序\",\n        \"sortByModified\": \"按修改时间排序\",\n        \"sortAsc\": \"升序排列\",\n        \"sortDesc\": \"降序排列\",\n        \"sort\": \"排序\",\n        \"hideCloudFiles\": \"隐藏云端文件\",\n        \"showCloudFiles\": \"显示云端文件\",\n        \"processingVectors\": \"正在处理向量数据\",\n        \"calculateVectors\": \"知识库计算（全量）\",\n        \"importMarkdown\": \"导入\",\n        \"importing\": \"正在导入...\",\n        \"importSuccess\": \"导入成功\",\n        \"importSuccessDesc\": \"成功导入 {count} 个文件\",\n        \"importError\": \"导入失败\"\n      },\n      \"sync\": {\n        \"syncingRemote\": \"正在拉取远程文件\",\n        \"syncComplete\": \"同步完成\",\n        \"pullingRemote\": \"正在从远程服务器获取最新内容...\",\n        \"pullComplete\": \"拉取完成\"\n      },\n      \"context\": {\n        \"viewDirectory\": \"查看目录\",\n        \"cut\": \"剪切\",\n        \"copy\": \"复制\",\n        \"paste\": \"粘贴\",\n        \"rename\": \"重命名\",\n        \"deleteSyncFile\": \"删除同步文件\",\n        \"deleteLocalFile\": \"删除本地文件\",\n        \"delete\": \"删除\",\n        \"confirmDelete\": \"确定要删除文件夹 \\\"{name}\\\" 吗？此操作将删除文件夹及其所有内容。\",\n        \"deleteSuccess\": \"删除成功\",\n        \"deleteFailed\": \"删除失败\",\n        \"newFile\": \"新建文件\",\n        \"newFolder\": \"新建文件夹\",\n        \"syncFolder\": \"同步\",\n        \"syncFolderDesc\": \"同步当前文件夹下的所有 Markdown 文件\",\n        \"syncFolderSuccess\": \"文件夹同步成功\",\n        \"syncFolderError\": \"文件夹同步失败\",\n        \"syncFolderProgress\": \"正在同步文件夹...\",\n        \"deleteSyncFileSuccess\": \"删除成功\",\n        \"deleteSyncFileError\": \"删除失败\",\n        \"knowledgeBase\": \"知识库\",\n        \"calculateVectors\": \"计算向量\",\n        \"updateVectors\": \"更新向量\",\n        \"deleteVectors\": \"删除向量\",\n        \"includeInKB\": \"包含在知识库\",\n        \"includeInKBFile\": \"包含在知识库中\",\n        \"autoVectorCalc\": \"自动向量计算\",\n        \"vectorCalculated\": \"向量已更新\",\n        \"vectorCalcCompleted\": \"向量计算完成\",\n        \"vectorCalcFailed\": \"向量计算失败\",\n        \"vectorDeleted\": \"向量已删除\",\n        \"vectorDeleteFailed\": \"删除向量失败\",\n        \"batchCalcSuccess\": \"成功计算 {count} 个文件的向量\",\n        \"batchCalcPartial\": \"计算完成：成功 {success} 个，失败 {failed} 个\",\n        \"batchCalcFailed\": \"批量计算向量失败\",\n        \"batchDeleteSuccess\": \"成功删除 {count} 个文件的向量\",\n        \"batchDeletePartial\": \"删除完成：成功 {success} 个，失败 {failed} 个\",\n        \"batchDeleteFailed\": \"批量删除向量失败\",\n        \"noMarkdownFiles\": \"文件夹中没有 Markdown 文件\",\n        \"includedInKB\": \"已包含在知识库中\",\n        \"excludedFromKB\": \"已从知识库中排除\",\n        \"autoCalcEnabled\": \"已启用自动向量计算\",\n        \"autoCalcDisabled\": \"已禁用自动向量计算\",\n        \"settingFailed\": \"设置失败\",\n        \"confirmDeleteVectors\": \"确定要删除 {count} 个文件的向量吗？\"\n      },\n      \"folderView\": {\n        \"vectorDbNotEnabled\": \"向量数据库未启用\",\n        \"calculateVectors\": \"计算向量\",\n        \"indexed\": \"已计算\",\n        \"vectorCount\": \"向量数\",\n        \"databaseSize\": \"数据库大小\",\n        \"lastCalculated\": \"最后计算\",\n        \"never\": \"从未\",\n        \"calculating\": \"计算中...\",\n        \"failed\": \"失败\",\n        \"recalculateVectors\": \"重新计算向量\",\n        \"skills\": \"Skills\",\n        \"skillNotFound\": \"Skill 未找到\",\n        \"skillNotFoundDesc\": \"无法找到 ID 为 {id} 的 Skill\",\n        \"loadingSkills\": \"加载 Skills...\",\n        \"loadingSkill\": \"加载 Skill...\",\n        \"globalSkills\": \"全局 Skills\",\n        \"workspaceSkills\": \"工作区 Skills\",\n        \"instructions\": \"指令\",\n        \"examples\": \"示例\",\n        \"scripts\": \"脚本\",\n        \"references\": \"参考文档\",\n        \"assets\": \"静态资源\"\n      },\n      \"error\": {\n        \"fileExists\": \"文件名已存在\"\n      },\n      \"clipboard\": {\n        \"copied\": \"已复制到剪贴板\",\n        \"cut\": \"已剪切到剪贴板\",\n        \"pasted\": \"已粘贴成功\",\n        \"pasteFailed\": \"粘贴操作失败\",\n        \"empty\": \"剪贴板为空\",\n        \"confirmOverwrite\": \"文件已存在，是否覆盖？\",\n        \"notSupported\": \"不支持此操作\"\n      },\n      \"mobile\": {\n        \"cancel\": \"取消\",\n        \"create\": \"创建\",\n        \"save\": \"保存\",\n        \"emptyDir\": \"该目录为空\",\n        \"root\": \"根目录\",\n        \"openFiles\": \"打开文件\",\n        \"remote\": \"远程文件\",\n        \"remoteFileNotPulled\": \"仅云端 · 点击后拉取\",\n        \"remoteFolderOnly\": \"仅云端目录\",\n        \"file\": \"文件\",\n        \"folder\": \"文件夹\",\n        \"folderChildren\": \"{files} 个文件 · {folders} 个文件夹\",\n        \"filePlaceholder\": \"示例.md\",\n        \"folderPlaceholder\": \"示例文件夹\"\n      },\n      \"deleteConfirm\": \"确认删除此文件吗？\"\n    },\n    \"editor\": {\n      \"copySuccess\": \"复制成功\",\n      \"copySuccessDescription\": \"已复制到剪贴板\",\n      \"search\": {\n        \"placeholder\": \"在文档中查找\",\n        \"replacePlaceholder\": \"替换为\",\n        \"caseSensitive\": \"区分大小写\",\n        \"replace\": \"替换\",\n        \"replaceAll\": \"全部替换\",\n        \"findPrev\": \"上一个\",\n        \"findNext\": \"下一个\"\n      },\n      \"floatbar\": {\n        \"quote\": {\n          \"tooltip\": \"引用\"\n        },\n        \"readAloud\": {\n          \"start\": \"朗读\",\n          \"stop\": \"停止朗读\",\n          \"loading\": \"正在加载...\"\n        }\n      },\n      \"upload\": {\n        \"error\": \"上传失败\",\n        \"needToken\": \"上传图片需配置 accessToken\",\n        \"uploading\": \"正在上传图片\"\n      },\n      \"saveDialog\": {\n        \"title\": \"保存文件\",\n        \"emptyContent\": \"内容为空\",\n        \"emptyContentDesc\": \"请先输入内容后再保存\",\n        \"success\": \"保存成功\",\n        \"successDesc\": \"文件已保存\",\n        \"error\": \"保存失败\",\n        \"errorDesc\": \"文件保存失败，请重试\"\n      },\n      \"toolbar\": {\n        \"mark\": {\n          \"title\": \"使用记录\",\n          \"tooltip\": \"使用记录\",\n          \"description\": \"消耗记录转化为内容插入到文章。\",\n          \"noRecords\": \"暂无记录\",\n          \"ocrNoContent\": \"OCR 未识别到任何内容\"\n        },\n        \"question\": {\n          \"tooltip\": \"问答\",\n          \"selectContent\": \"请先选择一段内容\",\n          \"promptTemplate\": \"参考原文：\\n{content}\\n根据提问：\\n{question}\\n，直接返回回答内容。\"\n        },\n        \"continue\": {\n          \"tooltip\": \"续写\",\n          \"promptTemplate\": \"根据前文：\\n{content}\\n内容，直接返回续写内容，不要超过100字。\\n内容可以参考后文：\\n{endContent}\\n，不要与后文内容重复。\"\n        },\n        \"polish\": {\n          \"tooltip\": \"润色\",\n          \"selectContent\": \"请先选择一段内容\",\n          \"promptTemplate\": \"润色这段文字：\\n{content}\\n要求语言不变，修复错别字和病句，直接返回润色后的结果。\"\n        },\n        \"eraser\": {\n          \"tooltip\": \"精简\",\n          \"selectContent\": \"请先选择一段内容\",\n          \"promptTemplate\": \"精简这段文字：\\n{content}\\n这段文字过于臃肿，字数要求缩减一半以上，要求语言不变，直接返回优化后的结果。\"\n        },\n        \"expansion\": {\n          \"tooltip\": \"扩写\",\n          \"selectContent\": \"请先选择一段内容\",\n          \"promptTemplate\": \"扩写这段文字：\\n{content}\\n这段文字过于简短，字数要求增加一倍以上，要求语言不变，直接返回扩写后的结果。\"\n        },\n        \"translation\": {\n          \"tooltip\": \"翻译\",\n          \"description\": \"将选中的文本进行翻译\",\n          \"selectContent\": \"请先选择一段内容\",\n          \"promptTemplate\": \"将这段文字：\\n{content}\\n翻译为{language}语言，直接返回翻译后的结果。\",\n          \"fail\": \"翻译失败\",\n          \"failNoSelection\": \"请先选择要翻译的文本\",\n          \"translating\": \"翻译中\",\n          \"translatingTo\": \"正在翻译为 {language}...\",\n          \"success\": \"翻译完成\",\n          \"successTo\": \"已翻译为 {language}\",\n          \"customLanguage\": \"自定义语言...\",\n          \"customLanguagePlaceholder\": \"输入目标语言，如：英语、日语等\",\n          \"customLanguageEmpty\": \"请输入目标语言\",\n          \"customLanguageExample\": \"例如：英语、日语、法语等\"\n        }\n      }\n    },\n    \"footer\": {\n      \"wordCount\": \"字数\",\n      \"pull\": {\n        \"pull\": \"拉取\",\n        \"checking\": \"检查更新中...\",\n        \"noUpdate\": \"无远程更新\",\n        \"clickToPull\": \"点击拉取远程更新\",\n        \"pullSuccess\": \"拉取成功\",\n        \"pullFailed\": \"拉取失败\",\n        \"ignored\": \"已忽略\",\n        \"ignoreUpdate\": \"忽略此更新\"\n      },\n      \"sync\": {\n        \"push\": \"推送\",\n        \"pushed\": \"已推送\",\n        \"syncing\": \"推送中\",\n        \"syncFailed\": \"推送失败\",\n        \"checkNetworkOrToken\": \"请检查网络连接或令牌是否正确\",\n        \"quickSync\": \"快速同步\"\n      },\n      \"history\": {\n        \"loadingHistory\": \"正在读取历史记录\",\n        \"historyRecords\": \"历史记录\",\n        \"noHistory\": \"无历史记录\",\n        \"loading\": \"加载中\",\n        \"recordsCount\": \"条记录\",\n        \"filterQuickSync\": \"过滤快速同步\",\n        \"committedAt\": \"提交于\",\n        \"pull\": \"拉取\",\n        \"quickSync\": \"快速同步\"\n      },\n      \"vectorCalc\": {\n        \"tooltip\": {\n          \"default\": \"向量索引状态\",\n          \"none\": \"点击开始向量计算\",\n          \"indexed\": \"已索引\",\n          \"pending\": \"待更新，点击立即计算\",\n          \"calculating\": \"计算中...\"\n        },\n        \"status\": {\n          \"calculating\": \"计算中\"\n        }\n      }\n    }\n  },\n  \"mobile\": {\n    \"chat\": {\n      \"drawer\": {\n        \"settings\": {\n          \"title\": \"对话设置\"\n        },\n        \"tools\": {\n          \"title\": \"工具\",\n          \"newChat\": \"开始新对话\",\n          \"start\": \"开始\"\n        },\n        \"attachments\": {\n          \"title\": \"附件\",\n          \"gallery\": \"相册\",\n          \"camera\": \"照相\",\n          \"file\": \"文件\",\n          \"linkNote\": \"关联笔记\"\n        }\n      }\n    }\n  },\n  \"mcp\": {\n    \"selectServers\": \"MCP 服务器\",\n    \"searchServers\": \"搜索服务器...\",\n    \"noServers\": \"未启用 MCP 服务功能\",\n    \"noServersFound\": \"未找到匹配的服务器\",\n    \"addServer\": \"添加服务器...\",\n    \"goToSettings\": \"前往设置\",\n    \"close\": \"关闭\",\n    \"navigate\": \"选择\",\n    \"confirm\": \"确认\",\n    \"tools\": \"个工具\",\n    \"connecting\": \"连接中\",\n    \"disconnected\": \"未连接\"\n  },\n  \"recording\": {\n    \"title\": \"录音识别\",\n    \"description\": \"点击麦克风按钮开始录音，系统将自动识别并转换为文字记录\",\n    \"recording\": \"录音中\",\n    \"paused\": \"已暂停\",\n    \"ready\": \"准备就绪\",\n    \"processing\": \"识别中...\",\n    \"cancel\": \"取消\",\n    \"error\": \"错误\",\n    \"success\": \"成功\",\n    \"noModelConfigured\": \"未配置语音识别模型，请先在设置中配置\",\n    \"speechUnavailable\": \"当前识别方式不可用，请检查本地语音支持或模型配置\",\n    \"fallbackToModel\": \"本地语音识别不可用，已自动切换为模型识别\",\n    \"startError\": \"无法启动录音\",\n    \"noAudioData\": \"未录制到音频数据\",\n    \"transcriptionSuccess\": \"语音识别完成\",\n    \"transcriptionEmpty\": \"识别结果为空\",\n    \"transcriptionError\": \"语音识别失败\",\n    \"configureModel\": \"配置模型\",\n    \"retryTranscription\": \"重新识别\",\n    \"retrying\": \"重新识别中...\",\n    \"retrySuccess\": \"重新识别完成\",\n    \"retryError\": \"重新识别失败\",\n    \"noContentDetected\": \"未识别到内容\",\n    \"doubleClickToSelectFile\": \"双击选择音频文件\",\n    \"mode\": {\n      \"builtin\": \"浏览器识别\",\n      \"builtinDesc\": \"免费，实时识别\",\n      \"model\": \"大模型识别\",\n      \"modelDesc\": \"需配置STT模型，更准确\"\n    }\n  },\n  \"quickRecord\": {\n    \"description\": \"点击选择记录工具，快速创建记录\"\n  },\n  \"editor\": {\n    \"placeholder\": \"输入 / 唤起菜单，或直接开始写作...\",\n    \"outline\": {\n      \"title\": \"大纲\",\n      \"open\": \"打开大纲\",\n      \"close\": \"关闭大纲\"\n    },\n    \"translation\": {\n      \"fail\": \"翻译失败\",\n      \"failNoSelection\": \"请先选择要翻译的文本\",\n      \"translating\": \"翻译中\",\n      \"translatingTo\": \"正在翻译为 {language}...\",\n      \"success\": \"翻译完成\",\n      \"successTo\": \"已翻译为 {language}\",\n      \"customLanguageEmpty\": \"请输入目标语言\",\n      \"customLanguageExample\": \"例如：英语、日语、法语等\"\n    },\n    \"quoteDisplay\": {\n      \"fromFile\": \"引用自 {fileName}\",\n      \"line\": \"引用自 {fileName} 第 {line} 行\",\n      \"lines\": \"引用自 {fileName} 第 {start}-{end} 行\"\n    },\n    \"bubbleMenu\": {\n      \"ai\": \"AI\",\n      \"polish\": \"润色\",\n      \"concise\": \"精简\",\n      \"expand\": \"扩展\",\n      \"translate\": \"翻译\",\n      \"translateSubtitle\": \"翻译为\",\n      \"quoteToChat\": \"引用到对话\",\n      \"link\": \"链接\",\n      \"linkPlaceholder\": \"输入链接地址\",\n      \"confirm\": \"确认\",\n      \"cancel\": \"取消\",\n      \"bold\": \"粗体\",\n      \"italic\": \"斜体\",\n      \"strike\": \"删除线\",\n      \"underline\": \"下划线\",\n      \"inlineCode\": \"行内代码\",\n      \"highlight\": \"高亮\",\n      \"blockquote\": \"引用\",\n      \"bulletList\": \"无序列表\",\n      \"orderedList\": \"有序列表\",\n      \"taskList\": \"任务列表\",\n      \"codeBlock\": \"代码块\",\n      \"languages\": {\n        \"English\": \"英语\",\n        \"Japanese\": \"日语\",\n        \"Korean\": \"韩语\",\n        \"French\": \"法语\",\n        \"German\": \"德语\",\n        \"Spanish\": \"西班牙语\",\n        \"Portuguese\": \"葡萄牙语\",\n        \"Russian\": \"俄语\",\n        \"Arabic\": \"阿拉伯语\"\n      },\n      \"customLanguagePlaceholder\": \"自定义语言...\"\n    },\n    \"aiSuggestion\": {\n      \"accept\": \"接受\",\n      \"reject\": \"拒绝\",\n      \"generating\": \"生成中...\",\n      \"abort\": \"终止\"\n    },\n    \"image\": {\n      \"insert\": \"插入图片\",\n      \"uploading\": \"上传中...\",\n      \"uploadSuccess\": \"图片已上传到图床\",\n      \"saveSuccess\": \"图片已保存到本地\",\n      \"uploadFailed\": \"插入图片失败\",\n      \"sizeSmall\": \"小 (25%)\",\n      \"sizeMedium\": \"中 (50%)\",\n      \"sizeLarge\": \"大 (75%)\",\n      \"sizeOriginal\": \"原始尺寸\",\n      \"editAlt\": \"编辑替代文本\",\n      \"editSrc\": \"编辑地址\",\n      \"altPlaceholder\": \"输入替代文本...\",\n      \"srcPlaceholder\": \"输入图片地址...\",\n      \"delete\": \"删除图片\",\n      \"confirm\": \"确认\",\n      \"cancel\": \"取消\"\n    },\n    \"mermaid\": {\n      \"rendering\": \"渲染中...\",\n      \"renderError\": \"渲染错误\",\n      \"clickToEdit\": \"点击编辑源码\",\n      \"clickToAdd\": \"点击添加图表代码\",\n      \"placeholder\": \"输入 Mermaid 图表代码...\",\n      \"preview\": \"预览\",\n      \"done\": \"完成\",\n      \"diagramTypes\": {\n        \"flowchart\": \"流程图\",\n        \"sequence\": \"时序图\",\n        \"classDiagram\": \"类图\",\n        \"stateDiagram\": \"状态图\",\n        \"er\": \"ER图\",\n        \"gantt\": \"甘特图\",\n        \"pie\": \"饼图\",\n        \"journey\": \"旅程图\"\n      },\n      \"templates\": {\n        \"flowchart\": \"graph TD\\n    A[开始] --> B[处理]\\n    B --> C[结束]\",\n        \"sequence\": \"sequenceDiagram\\n    participant Alice\\n    participant Bob\\n    Alice->>Bob: 你好\\n    Bob-->>Alice: 回复\",\n        \"classDiagram\": \"classDiagram\\n    Animal <|-- Duck\\n    Animal <|-- Fish\\n    Animal : +int age\\n    Animal : +String gender\",\n        \"stateDiagram\": \"stateDiagram-v2\\n    [*] --> Active\\n    Active --> [*]\",\n        \"er\": \"erDiagram\\n    CUSTOMER ||--o{ ORDER : places\\n    CUSTOMER ||--o{ DELIVERY-ADDRESS : uses\",\n        \"gantt\": \"gantt\\n    title 项目计划\\n    dateFormat YYYY-MM-DD\\n    section 第一阶段\\n    任务1 :a1, 2024-01-01, 30d\\n    section 第二阶段\\n    任务2 :after a1, 20d\",\n        \"pie\": \"pie title 资源分配\\n    \\\"CPU\\\" : 45\\n    \\\"内存\\\" : 30\\n    \\\"存储\\\" : 25\",\n        \"journey\": \"journey\\n    title 我的日常工作\\n    section 上午\\n    通勤 : 7:00, 5\\n    工作 : 9:00, 8\"\n      }\n    },\n    \"slashCommand\": {\n      \"groups\": {\n        \"ai\": \"AI\",\n        \"heading\": \"标题\",\n        \"list\": \"列表\",\n        \"block\": \"块级\",\n        \"align\": \"对齐\",\n        \"embed\": \"嵌入\",\n        \"math\": \"数学\",\n        \"chart\": \"图表\"\n      },\n      \"items\": {\n        \"continue\": \"续写\",\n        \"continueDesc\": \"AI 续写内容\",\n        \"heading1\": \"标题1\",\n        \"heading1Desc\": \"大标题\",\n        \"heading2\": \"标题2\",\n        \"heading2Desc\": \"中标题\",\n        \"heading3\": \"标题3\",\n        \"heading3Desc\": \"小标题\",\n        \"bulletList\": \"无序列表\",\n        \"bulletListDesc\": \"创建简单的项目列表\",\n        \"orderedList\": \"有序列表\",\n        \"orderedListDesc\": \"创建带编号的列表\",\n        \"taskList\": \"任务列表\",\n        \"taskListDesc\": \"创建带复选框的任务列表\",\n        \"image\": \"图片\",\n        \"imageDesc\": \"插入本地图片或图床图片\",\n        \"table\": \"表格\",\n        \"tableDesc\": \"插入表格\",\n        \"blockquote\": \"引用\",\n        \"blockquoteDesc\": \"捕获引用内容\",\n        \"codeBlock\": \"代码块\",\n        \"codeBlockDesc\": \"捕获代码片段\",\n        \"divider\": \"分割线\",\n        \"dividerDesc\": \"在元素之间创建分隔线\",\n        \"inlineMath\": \"行内公式\",\n        \"inlineMathDesc\": \"插入行内 LaTeX 公式\",\n        \"blockMath\": \"块级公式\",\n        \"blockMathDesc\": \"插入块级 LaTeX 公式\",\n        \"flowchart\": \"流程图\",\n        \"flowchartDesc\": \"插入流程图\",\n        \"sequence\": \"时序图\",\n        \"sequenceDesc\": \"插入时序图\",\n        \"gantt\": \"甘特图\",\n        \"ganttDesc\": \"插入甘特图\",\n        \"classDiagram\": \"类图\",\n        \"classDiagramDesc\": \"插入类图\",\n        \"stateDiagram\": \"状态图\",\n        \"stateDiagramDesc\": \"插入状态图\",\n        \"pie\": \"饼图\",\n        \"pieDesc\": \"插入饼图\",\n        \"erDiagram\": \"ER图\",\n        \"erDiagramDesc\": \"插入实体关系图\",\n        \"journey\": \"旅程图\",\n        \"journeyDesc\": \"插入用户旅程图\"\n      },\n      \"imageUpload\": {\n        \"success\": \"上传成功\",\n        \"saveSuccess\": \"保存成功\",\n        \"savePath\": \"保存路径: __PATH__\",\n        \"failed\": \"插入图片失败\"\n      }\n    }\n  },\n  \"tabContext\": {\n    \"close\": \"关闭\",\n    \"closeOthers\": \"关闭其他\",\n    \"closeAll\": \"关闭全部\",\n    \"closeLeft\": \"关闭左侧\",\n    \"closeRight\": \"关闭右侧\"\n  }\n}\n"
  },
  {
    "path": "next.config.ts",
    "content": "import createNextIntlPlugin from 'next-intl/plugin';\nimport type { NextConfig } from \"next\";\n\nconst isProd = process.env.NODE_ENV === 'production';\nconst internalHost = process.env.TAURI_DEV_HOST || 'localhost';\n\nconst withNextIntl = createNextIntlPlugin();\n\nconst nextConfig: NextConfig = {\n  /* config options here */\n  output: \"export\",\n  images: {\n    unoptimized: true,\n  },\n  assetPrefix: isProd ? undefined : `http://${internalHost}:3456`,\n  sassOptions: {\n    silenceDeprecations: ['legacy-js-api'],\n  },\n  reactStrictMode: false,\n  turbopack: {},\n  devIndicators: false,\n  webpack: (config) => {\n    // 过滤掉 flushSync 警告 - 来自 Tiptap 编辑器的已知问题\n    config.stats = {\n      ...config.stats,\n      warningsFilter: (warning: string) => {\n        return !warning.includes('flushSync');\n      }\n    };\n    return config;\n  }\n};\n\nexport default withNextIntl(nextConfig);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"note-gen\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev --turbopack -p 3456 -H 0.0.0.0\",\n    \"build\": \"next build --turbopack\",\n    \"start\": \"next start --turbopack -p 3456\",\n    \"lint\": \"next lint\",\n    \"tauri\": \"tauri\",\n    \"docs:build\": \"npm --prefix ./docs run build\",\n    \"sync-version\": \"./scripts/sync-version.sh\",\n    \"ios-build\": \"npm run sync-version && pnpm tauri ios build --open\"\n  },\n  \"dependencies\": {\n    \"@antv/infographic\": \"^0.2.7\",\n    \"@codemirror/commands\": \"^6.7.1\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@hookform/resolvers\": \"^3.9.1\",\n    \"@octokit/core\": \"^6.1.2\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.6\",\n    \"@radix-ui/react-avatar\": \"^1.1.2\",\n    \"@radix-ui/react-checkbox\": \"^1.1.2\",\n    \"@radix-ui/react-collapsible\": \"^1.1.1\",\n    \"@radix-ui/react-context-menu\": \"^2.2.2\",\n    \"@radix-ui/react-dialog\": \"^1.1.2\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.2\",\n    \"@radix-ui/react-hover-card\": \"^1.1.2\",\n    \"@radix-ui/react-label\": \"^2.1.0\",\n    \"@radix-ui/react-popover\": \"^1.1.2\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-radio-group\": \"^1.2.1\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.6\",\n    \"@radix-ui/react-select\": \"^2.1.2\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slider\": \"^1.2.3\",\n    \"@radix-ui/react-slot\": \"^1.2.0\",\n    \"@radix-ui/react-switch\": \"^1.1.1\",\n    \"@radix-ui/react-tabs\": \"^1.1.1\",\n    \"@radix-ui/react-toast\": \"^1.2.2\",\n    \"@radix-ui/react-toggle\": \"^1.1.0\",\n    \"@radix-ui/react-tooltip\": \"^1.1.3\",\n    \"@sereneinserenade/tiptap-search-and-replace\": \"^0.1.1\",\n    \"@tauri-apps/api\": \">=2.0.0\",\n    \"@tauri-apps/plugin-clipboard-manager\": \"~2\",\n    \"@tauri-apps/plugin-dialog\": \"~2\",\n    \"@tauri-apps/plugin-fs\": \"~2\",\n    \"@tauri-apps/plugin-global-shortcut\": \"~2.2.0\",\n    \"@tauri-apps/plugin-http\": \"~2.3.0\",\n    \"@tauri-apps/plugin-opener\": \"^2.4.0\",\n    \"@tauri-apps/plugin-os\": \"~2.2.1\",\n    \"@tauri-apps/plugin-process\": \"~2.2.1\",\n    \"@tauri-apps/plugin-shell\": \"~2\",\n    \"@tauri-apps/plugin-sql\": \"~2\",\n    \"@tauri-apps/plugin-store\": \"~2\",\n    \"@tauri-apps/plugin-updater\": \"~2\",\n    \"@tauri-apps/plugin-window-state\": \"~2\",\n    \"@tiptap/core\": \"^3.19.0\",\n    \"@tiptap/extension-bubble-menu\": \"^3.19.0\",\n    \"@tiptap/extension-character-count\": \"^3.19.0\",\n    \"@tiptap/extension-code-block-lowlight\": \"^3.19.0\",\n    \"@tiptap/extension-color\": \"^3.19.0\",\n    \"@tiptap/extension-dropcursor\": \"^3.19.0\",\n    \"@tiptap/extension-floating-menu\": \"^3.19.0\",\n    \"@tiptap/extension-highlight\": \"^3.19.0\",\n    \"@tiptap/extension-horizontal-rule\": \"^3.19.0\",\n    \"@tiptap/extension-image\": \"^3.19.0\",\n    \"@tiptap/extension-link\": \"^3.19.0\",\n    \"@tiptap/extension-mathematics\": \"^3.19.0\",\n    \"@tiptap/extension-mention\": \"^3.19.0\",\n    \"@tiptap/extension-placeholder\": \"^3.19.0\",\n    \"@tiptap/extension-table\": \"^3.19.0\",\n    \"@tiptap/extension-table-cell\": \"^3.19.0\",\n    \"@tiptap/extension-table-header\": \"^3.19.0\",\n    \"@tiptap/extension-table-row\": \"^3.19.0\",\n    \"@tiptap/extension-task-item\": \"^3.19.0\",\n    \"@tiptap/extension-task-list\": \"^3.19.0\",\n    \"@tiptap/extension-text-align\": \"^3.19.0\",\n    \"@tiptap/extension-text-style\": \"^3.19.0\",\n    \"@tiptap/extension-typography\": \"^3.19.0\",\n    \"@tiptap/extension-underline\": \"^3.19.0\",\n    \"@tiptap/extension-unique-id\": \"^3.19.0\",\n    \"@tiptap/extension-youtube\": \"^3.19.0\",\n    \"@tiptap/markdown\": \"^3.19.0\",\n    \"@tiptap/pm\": \"^3.19.0\",\n    \"@tiptap/react\": \"^3.19.0\",\n    \"@tiptap/starter-kit\": \"^3.19.0\",\n    \"@tiptap/suggestion\": \"^3.19.0\",\n    \"@types/lodash-es\": \"^4.17.12\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"1.0.0\",\n    \"cropperjs\": \"1\",\n    \"crypto-js\": \"^4.2.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"date-fns-tz\": \"^3.2.0\",\n    \"dayjs\": \"^1.11.13\",\n    \"diff\": \"^7.0.0\",\n    \"embla-carousel-react\": \"^8.6.0\",\n    \"framer-motion\": \"^12.26.2\",\n    \"github-markdown-css\": \"^5.8.1\",\n    \"highlight.js\": \"^11.11.1\",\n    \"hotkeys-js\": \"^3.13.15\",\n    \"html2canvas\": \"^1.4.1\",\n    \"jspdf\": \"^3.0.4\",\n    \"katex\": \"^0.16.28\",\n    \"lodash\": \"^4.17.21\",\n    \"lodash-es\": \"^4.17.21\",\n    \"lowlight\": \"^3.3.0\",\n    \"lucide-react\": \"^0.561.0\",\n    \"markdown-it\": \"^14.1.0\",\n    \"mermaid\": \"^11.12.2\",\n    \"mitt\": \"^3.0.1\",\n    \"next\": \"^15.1.0\",\n    \"next-intl\": \"^3.26.5\",\n    \"next-themes\": \"^0.4.3\",\n    \"openai\": \"^4.78.1\",\n    \"pdfjs-dist\": \"^4.10.38\",\n    \"pinyin\": \"^4.0.0\",\n    \"react\": \"19.1.0\",\n    \"react-advanced-cropper\": \"^0.20.1\",\n    \"react-day-picker\": \"^9.13.0\",\n    \"react-dom\": \"19.1.0\",\n    \"react-hook-form\": \"^7.53.2\",\n    \"react-photo-view\": \"^1.2.6\",\n    \"react-resizable-panels\": \"^2.1.7\",\n    \"react-use\": \"^17.6.0\",\n    \"tailwind-merge\": \"^2.5.4\",\n    \"tailwindcss-safe-area\": \"^0.6.0\",\n    \"tauri-plugin-clipboard-api\": \"^2.1.11\",\n    \"tesseract.js\": \"^5.1.1\",\n    \"tippy.js\": \"^6.3.7\",\n    \"usehooks-ts\": \"^3.1.1\",\n    \"uuid\": \"^11.0.3\",\n    \"vaul\": \"^1.1.1\",\n    \"words-count\": \"^2.0.2\",\n    \"zod\": \"^3.23.8\",\n    \"zustand\": \"^5.0.1\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/postcss\": \"^4.1.18\",\n    \"@tailwindcss/vite\": \"^4.1.18\",\n    \"@tauri-apps/cli\": \">=2.0.0\",\n    \"@types/crypto-js\": \"^4.2.2\",\n    \"@types/diff\": \"^6.0.0\",\n    \"@types/markdown-it\": \"^14.1.2\",\n    \"@types/node\": \"^20\",\n    \"@types/react\": \"^18\",\n    \"@types/react-dom\": \"^18\",\n    \"eslint\": \"^8\",\n    \"eslint-config-next\": \"15.0.3\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"^5\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.mjs",
    "content": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    \"@tailwindcss/postcss\": {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "public/markdown/github-markdown-dark.css",
    "content": "/* dark */\n.markdown-body {\n  color-scheme: dark;\n  -ms-text-size-adjust: 100%;\n  -webkit-text-size-adjust: 100%;\n  margin: 0;\n  color: #f0f6fc;\n  background-color: #0d1117;\n  font-family: -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Noto Sans\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";\n  font-size: 16px;\n  line-height: 1.5;\n  word-wrap: break-word;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  fill: currentColor;\n  vertical-align: text-bottom;\n}\n\n.markdown-body h1:hover .anchor .octicon-link:before,\n.markdown-body h2:hover .anchor .octicon-link:before,\n.markdown-body h3:hover .anchor .octicon-link:before,\n.markdown-body h4:hover .anchor .octicon-link:before,\n.markdown-body h5:hover .anchor .octicon-link:before,\n.markdown-body h6:hover .anchor .octicon-link:before {\n  width: 16px;\n  height: 16px;\n  content: ' ';\n  display: inline-block;\n  background-color: currentColor;\n  -webkit-mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n  mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n}\n\n.markdown-body details,\n.markdown-body figcaption,\n.markdown-body figure {\n  display: block;\n}\n\n.markdown-body summary {\n  display: list-item;\n}\n\n.markdown-body [hidden] {\n  display: none !important;\n}\n\n.markdown-body a {\n  background-color: transparent;\n  color: #4493f8;\n  text-decoration: none;\n}\n\n.markdown-body abbr[title] {\n  border-bottom: none;\n  -webkit-text-decoration: underline dotted;\n  text-decoration: underline dotted;\n}\n\n.markdown-body b,\n.markdown-body strong {\n  font-weight: 600;\n}\n\n.markdown-body dfn {\n  font-style: italic;\n}\n\n.markdown-body h1 {\n  margin: .67em 0;\n  font-weight: 600;\n  padding-bottom: .3em;\n  font-size: 2em;\n  border-bottom: 1px solid #3d444db3;\n}\n\n.markdown-body mark {\n  background-color: #bb800926;\n  color: #f0f6fc;\n}\n\n.markdown-body small {\n  font-size: 90%;\n}\n\n.markdown-body sub,\n.markdown-body sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.markdown-body sub {\n  bottom: -0.25em;\n}\n\n.markdown-body sup {\n  top: -0.5em;\n}\n\n.markdown-body img {\n  border-style: none;\n  max-width: 100%;\n  box-sizing: content-box;\n}\n\n.markdown-body code,\n.markdown-body kbd,\n.markdown-body pre,\n.markdown-body samp {\n  font-family: monospace;\n  font-size: 1em;\n}\n\n.markdown-body figure {\n  margin: 1em 2.5rem;\n}\n\n.markdown-body hr {\n  box-sizing: content-box;\n  overflow: hidden;\n  background: transparent;\n  border-bottom: 1px solid #3d444db3;\n  height: .25em;\n  padding: 0;\n  margin: 1.5rem 0;\n  background-color: #3d444d;\n  border: 0;\n}\n\n.markdown-body input {\n  font: inherit;\n  margin: 0;\n  overflow: visible;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.markdown-body [type=button],\n.markdown-body [type=reset],\n.markdown-body [type=submit] {\n  -webkit-appearance: button;\n  appearance: button;\n}\n\n.markdown-body [type=checkbox],\n.markdown-body [type=radio] {\n  box-sizing: border-box;\n  padding: 0;\n}\n\n.markdown-body [type=number]::-webkit-inner-spin-button,\n.markdown-body [type=number]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.markdown-body [type=search]::-webkit-search-cancel-button,\n.markdown-body [type=search]::-webkit-search-decoration {\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-body ::-webkit-input-placeholder {\n  color: inherit;\n  opacity: .54;\n}\n\n.markdown-body ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  appearance: button;\n  font: inherit;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body ::placeholder {\n  color: #9198a1;\n  opacity: 1;\n}\n\n.markdown-body hr::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body hr::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n  font-variant: tabular-nums;\n}\n\n.markdown-body td,\n.markdown-body th {\n  padding: 0;\n}\n\n.markdown-body details summary {\n  cursor: pointer;\n}\n\n.markdown-body a:focus,\n.markdown-body [role=button]:focus,\n.markdown-body input[type=radio]:focus,\n.markdown-body input[type=checkbox]:focus {\n  outline: 2px solid #1f6feb;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:focus:not(:focus-visible),\n.markdown-body [role=button]:focus:not(:focus-visible),\n.markdown-body input[type=radio]:focus:not(:focus-visible),\n.markdown-body input[type=checkbox]:focus:not(:focus-visible) {\n  outline: solid 1px transparent;\n}\n\n.markdown-body a:focus-visible,\n.markdown-body [role=button]:focus-visible,\n.markdown-body input[type=radio]:focus-visible,\n.markdown-body input[type=checkbox]:focus-visible {\n  outline: 2px solid #1f6feb;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:not([class]):focus,\n.markdown-body a:not([class]):focus-visible,\n.markdown-body input[type=radio]:focus,\n.markdown-body input[type=radio]:focus-visible,\n.markdown-body input[type=checkbox]:focus,\n.markdown-body input[type=checkbox]:focus-visible {\n  outline-offset: 0;\n}\n\n.markdown-body kbd {\n  display: inline-block;\n  padding: 0.25rem;\n  font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  line-height: 10px;\n  color: #f0f6fc;\n  vertical-align: middle;\n  background-color: #151b23;\n  border: solid 1px #3d444db3;\n  border-bottom-color: #3d444db3;\n  border-radius: 6px;\n  box-shadow: inset 0 -1px 0 #3d444db3;\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  margin-top: 1.5rem;\n  margin-bottom: 1rem;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.markdown-body h2 {\n  font-weight: 600;\n  padding-bottom: .3em;\n  font-size: 1.5em;\n  border-bottom: 1px solid #3d444db3;\n}\n\n.markdown-body h3 {\n  font-weight: 600;\n  font-size: 1.25em;\n}\n\n.markdown-body h4 {\n  font-weight: 600;\n  font-size: 1em;\n}\n\n.markdown-body h5 {\n  font-weight: 600;\n  font-size: .875em;\n}\n\n.markdown-body h6 {\n  font-weight: 600;\n  font-size: .85em;\n  color: #9198a1;\n}\n\n.markdown-body p {\n  margin-top: 0;\n  margin-bottom: 10px;\n}\n\n.markdown-body blockquote {\n  margin: 0;\n  padding: 0 1em;\n  color: #9198a1;\n  border-left: .25em solid #3d444d;\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  margin-top: 0;\n  margin-bottom: 0;\n  padding-left: 2em;\n}\n\n.markdown-body ol ol,\n.markdown-body ul ol {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ul ul ol,\n.markdown-body ul ol ol,\n.markdown-body ol ul ol,\n.markdown-body ol ol ol {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body dd {\n  margin-left: 0;\n}\n\n.markdown-body tt,\n.markdown-body code,\n.markdown-body samp {\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  font-size: 12px;\n}\n\n.markdown-body pre {\n  margin-top: 0;\n  margin-bottom: 0;\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  font-size: 12px;\n  word-wrap: normal;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  overflow: visible !important;\n  vertical-align: text-bottom;\n  fill: currentColor;\n}\n\n.markdown-body input::-webkit-outer-spin-button,\n.markdown-body input::-webkit-inner-spin-button {\n  margin: 0;\n  appearance: none;\n}\n\n.markdown-body .mr-2 {\n  margin-right: 0.5rem !important;\n}\n\n.markdown-body::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body>*:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body>*:last-child {\n  margin-bottom: 0 !important;\n}\n\n.markdown-body a:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\n.markdown-body .absent {\n  color: #f85149;\n}\n\n.markdown-body .anchor {\n  float: left;\n  padding-right: 0.25rem;\n  margin-left: -20px;\n  line-height: 1;\n}\n\n.markdown-body .anchor:focus {\n  outline: none;\n}\n\n.markdown-body p,\n.markdown-body blockquote,\n.markdown-body ul,\n.markdown-body ol,\n.markdown-body dl,\n.markdown-body table,\n.markdown-body pre,\n.markdown-body details {\n  margin-top: 0;\n  margin-bottom: 1rem;\n}\n\n.markdown-body blockquote>:first-child {\n  margin-top: 0;\n}\n\n.markdown-body blockquote>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body h1 .octicon-link,\n.markdown-body h2 .octicon-link,\n.markdown-body h3 .octicon-link,\n.markdown-body h4 .octicon-link,\n.markdown-body h5 .octicon-link,\n.markdown-body h6 .octicon-link {\n  color: #f0f6fc;\n  vertical-align: middle;\n  visibility: hidden;\n}\n\n.markdown-body h1:hover .anchor,\n.markdown-body h2:hover .anchor,\n.markdown-body h3:hover .anchor,\n.markdown-body h4:hover .anchor,\n.markdown-body h5:hover .anchor,\n.markdown-body h6:hover .anchor {\n  text-decoration: none;\n}\n\n.markdown-body h1:hover .anchor .octicon-link,\n.markdown-body h2:hover .anchor .octicon-link,\n.markdown-body h3:hover .anchor .octicon-link,\n.markdown-body h4:hover .anchor .octicon-link,\n.markdown-body h5:hover .anchor .octicon-link,\n.markdown-body h6:hover .anchor .octicon-link {\n  visibility: visible;\n}\n\n.markdown-body h1 tt,\n.markdown-body h1 code,\n.markdown-body h2 tt,\n.markdown-body h2 code,\n.markdown-body h3 tt,\n.markdown-body h3 code,\n.markdown-body h4 tt,\n.markdown-body h4 code,\n.markdown-body h5 tt,\n.markdown-body h5 code,\n.markdown-body h6 tt,\n.markdown-body h6 code {\n  padding: 0 .2em;\n  font-size: inherit;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2,\n.markdown-body summary h3,\n.markdown-body summary h4,\n.markdown-body summary h5,\n.markdown-body summary h6 {\n  display: inline-block;\n}\n\n.markdown-body summary h1 .anchor,\n.markdown-body summary h2 .anchor,\n.markdown-body summary h3 .anchor,\n.markdown-body summary h4 .anchor,\n.markdown-body summary h5 .anchor,\n.markdown-body summary h6 .anchor {\n  margin-left: -40px;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2 {\n  padding-bottom: 0;\n  border-bottom: 0;\n}\n\n.markdown-body ul.no-list,\n.markdown-body ol.no-list {\n  padding: 0;\n  list-style-type: none;\n}\n\n.markdown-body ol[type=\"a s\"] {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body ol[type=\"A s\"] {\n  list-style-type: upper-alpha;\n}\n\n.markdown-body ol[type=\"i s\"] {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ol[type=\"I s\"] {\n  list-style-type: upper-roman;\n}\n\n.markdown-body ol[type=\"1\"] {\n  list-style-type: decimal;\n}\n\n.markdown-body div>ol:not([type]) {\n  list-style-type: decimal;\n}\n\n.markdown-body ul ul,\n.markdown-body ul ol,\n.markdown-body ol ol,\n.markdown-body ol ul {\n  margin-top: 0;\n  margin-bottom: 0;\n}\n\n.markdown-body li>p {\n  margin-top: 1rem;\n}\n\n.markdown-body li+li {\n  margin-top: .25em;\n}\n\n.markdown-body dl {\n  padding: 0;\n}\n\n.markdown-body dl dt {\n  padding: 0;\n  margin-top: 1rem;\n  font-size: 1em;\n  font-style: italic;\n  font-weight: 600;\n}\n\n.markdown-body dl dd {\n  padding: 0 1rem;\n  margin-bottom: 1rem;\n}\n\n.markdown-body table th {\n  font-weight: 600;\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  padding: 6px 13px;\n  border: 1px solid #3d444d;\n}\n\n.markdown-body table td>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body table tr {\n  background-color: #0d1117;\n  border-top: 1px solid #3d444db3;\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: #151b23;\n}\n\n.markdown-body table img {\n  background-color: transparent;\n}\n\n.markdown-body img[align=right] {\n  padding-left: 20px;\n}\n\n.markdown-body img[align=left] {\n  padding-right: 20px;\n}\n\n.markdown-body .emoji {\n  max-width: none;\n  vertical-align: text-top;\n  background-color: transparent;\n}\n\n.markdown-body span.frame {\n  display: block;\n  overflow: hidden;\n}\n\n.markdown-body span.frame>span {\n  display: block;\n  float: left;\n  width: auto;\n  padding: 7px;\n  margin: 13px 0 0;\n  overflow: hidden;\n  border: 1px solid #3d444d;\n}\n\n.markdown-body span.frame span img {\n  display: block;\n  float: left;\n}\n\n.markdown-body span.frame span span {\n  display: block;\n  padding: 5px 0 0;\n  clear: both;\n  color: #f0f6fc;\n}\n\n.markdown-body span.align-center {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-center>span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: center;\n}\n\n.markdown-body span.align-center span img {\n  margin: 0 auto;\n  text-align: center;\n}\n\n.markdown-body span.align-right {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-right>span {\n  display: block;\n  margin: 13px 0 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body span.align-right span img {\n  margin: 0;\n  text-align: right;\n}\n\n.markdown-body span.float-left {\n  display: block;\n  float: left;\n  margin-right: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-left span {\n  margin: 13px 0 0;\n}\n\n.markdown-body span.float-right {\n  display: block;\n  float: right;\n  margin-left: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-right>span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body code,\n.markdown-body tt {\n  padding: .2em .4em;\n  margin: 0;\n  font-size: 85%;\n  white-space: break-spaces;\n  background-color: #656c7633;\n  border-radius: 6px;\n}\n\n.markdown-body code br,\n.markdown-body tt br {\n  display: none;\n}\n\n.markdown-body del code {\n  text-decoration: inherit;\n}\n\n.markdown-body samp {\n  font-size: 85%;\n}\n\n.markdown-body pre code {\n  font-size: 100%;\n}\n\n.markdown-body pre>code {\n  padding: 0;\n  margin: 0;\n  word-break: normal;\n  white-space: pre;\n  background: transparent;\n  border: 0;\n}\n\n.markdown-body .highlight {\n  margin-bottom: 1rem;\n}\n\n.markdown-body .highlight pre {\n  margin-bottom: 0;\n  word-break: normal;\n}\n\n.markdown-body .highlight pre,\n.markdown-body pre {\n  padding: 1rem;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  color: #f0f6fc;\n  background-color: #151b23;\n  border-radius: 6px;\n}\n\n.markdown-body pre code,\n.markdown-body pre tt {\n  display: inline;\n  max-width: auto;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-body .csv-data td,\n.markdown-body .csv-data th {\n  padding: 5px;\n  overflow: hidden;\n  font-size: 12px;\n  line-height: 1;\n  text-align: left;\n  white-space: nowrap;\n}\n\n.markdown-body .csv-data .blob-num {\n  padding: 10px 0.5rem 9px;\n  text-align: right;\n  background: #0d1117;\n  border: 0;\n}\n\n.markdown-body .csv-data tr {\n  border-top: 0;\n}\n\n.markdown-body .csv-data th {\n  font-weight: 600;\n  background: #151b23;\n  border-top: 0;\n}\n\n.markdown-body [data-footnote-ref]::before {\n  content: \"[\";\n}\n\n.markdown-body [data-footnote-ref]::after {\n  content: \"]\";\n}\n\n.markdown-body .footnotes {\n  font-size: 12px;\n  color: #9198a1;\n  border-top: 1px solid #3d444d;\n}\n\n.markdown-body .footnotes ol {\n  padding-left: 1rem;\n}\n\n.markdown-body .footnotes ol ul {\n  display: inline-block;\n  padding-left: 1rem;\n  margin-top: 1rem;\n}\n\n.markdown-body .footnotes li {\n  position: relative;\n}\n\n.markdown-body .footnotes li:target::before {\n  position: absolute;\n  top: calc(0.5rem*-1);\n  right: calc(0.5rem*-1);\n  bottom: calc(0.5rem*-1);\n  left: calc(1.5rem*-1);\n  pointer-events: none;\n  content: \"\";\n  border: 2px solid #1f6feb;\n  border-radius: 6px;\n}\n\n.markdown-body .footnotes li:target {\n  color: #f0f6fc;\n}\n\n.markdown-body .footnotes .data-footnote-backref g-emoji {\n  font-family: monospace;\n}\n\n.markdown-body body:has(:modal) {\n  padding-right: var(--dialog-scrollgutter) !important;\n}\n\n.markdown-body .pl-c {\n  color: #9198a1;\n}\n\n.markdown-body .pl-c1,\n.markdown-body .pl-s .pl-v {\n  color: #79c0ff;\n}\n\n.markdown-body .pl-e,\n.markdown-body .pl-en {\n  color: #d2a8ff;\n}\n\n.markdown-body .pl-smi,\n.markdown-body .pl-s .pl-s1 {\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-ent {\n  color: #7ee787;\n}\n\n.markdown-body .pl-k {\n  color: #ff7b72;\n}\n\n.markdown-body .pl-s,\n.markdown-body .pl-pds,\n.markdown-body .pl-s .pl-pse .pl-s1,\n.markdown-body .pl-sr,\n.markdown-body .pl-sr .pl-cce,\n.markdown-body .pl-sr .pl-sre,\n.markdown-body .pl-sr .pl-sra {\n  color: #a5d6ff;\n}\n\n.markdown-body .pl-v,\n.markdown-body .pl-smw {\n  color: #ffa657;\n}\n\n.markdown-body .pl-bu {\n  color: #f85149;\n}\n\n.markdown-body .pl-ii {\n  color: #f0f6fc;\n  background-color: #8e1519;\n}\n\n.markdown-body .pl-c2 {\n  color: #f0f6fc;\n  background-color: #b62324;\n}\n\n.markdown-body .pl-sr .pl-cce {\n  font-weight: bold;\n  color: #7ee787;\n}\n\n.markdown-body .pl-ml {\n  color: #f2cc60;\n}\n\n.markdown-body .pl-mh,\n.markdown-body .pl-mh .pl-en,\n.markdown-body .pl-ms {\n  font-weight: bold;\n  color: #1f6feb;\n}\n\n.markdown-body .pl-mi {\n  font-style: italic;\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-mb {\n  font-weight: bold;\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-md {\n  color: #ffdcd7;\n  background-color: #67060c;\n}\n\n.markdown-body .pl-mi1 {\n  color: #aff5b4;\n  background-color: #033a16;\n}\n\n.markdown-body .pl-mc {\n  color: #ffdfb6;\n  background-color: #5a1e02;\n}\n\n.markdown-body .pl-mi2 {\n  color: #f0f6fc;\n  background-color: #1158c7;\n}\n\n.markdown-body .pl-mdr {\n  font-weight: bold;\n  color: #d2a8ff;\n}\n\n.markdown-body .pl-ba {\n  color: #9198a1;\n}\n\n.markdown-body .pl-sg {\n  color: #3d444d;\n}\n\n.markdown-body .pl-corl {\n  text-decoration: underline;\n  color: #a5d6ff;\n}\n\n.markdown-body [role=button]:focus:not(:focus-visible),\n.markdown-body [role=tabpanel][tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body button:focus:not(:focus-visible),\n.markdown-body summary:focus:not(:focus-visible),\n.markdown-body a:focus:not(:focus-visible) {\n  outline: none;\n  box-shadow: none;\n}\n\n.markdown-body [tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body details-dialog:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.markdown-body g-emoji {\n  display: inline-block;\n  min-width: 1ch;\n  font-family: \"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\";\n  font-size: 1em;\n  font-style: normal !important;\n  font-weight: 400;\n  line-height: 1;\n  vertical-align: -0.075em;\n}\n\n.markdown-body g-emoji img {\n  width: 1em;\n  height: 1em;\n}\n\n.markdown-body .task-list-item {\n  list-style-type: none;\n}\n\n.markdown-body .task-list-item label {\n  font-weight: 400;\n}\n\n.markdown-body .task-list-item.enabled label {\n  cursor: pointer;\n}\n\n.markdown-body .task-list-item+.task-list-item {\n  margin-top: 0.25rem;\n}\n\n.markdown-body .task-list-item .handle {\n  display: none;\n}\n\n.markdown-body .task-list-item-checkbox {\n  margin: 0 .2em .25em -1.4em;\n  vertical-align: middle;\n}\n\n.markdown-body ul:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em .25em .2em;\n}\n\n.markdown-body ol:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em .25em .2em;\n}\n\n.markdown-body .contains-task-list:hover .task-list-item-convert-container,\n.markdown-body .contains-task-list:focus-within .task-list-item-convert-container {\n  display: block;\n  width: auto;\n  height: 24px;\n  overflow: visible;\n  clip: auto;\n}\n\n.markdown-body ::-webkit-calendar-picker-indicator {\n  filter: invert(50%);\n}\n\n.markdown-body .markdown-alert {\n  padding: 0.5rem 1rem;\n  margin-bottom: 1rem;\n  color: inherit;\n  border-left: .25em solid #3d444d;\n}\n\n.markdown-body .markdown-alert>:first-child {\n  margin-top: 0;\n}\n\n.markdown-body .markdown-alert>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body .markdown-alert .markdown-alert-title {\n  display: flex;\n  font-weight: 500;\n  align-items: center;\n  line-height: 1;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note {\n  border-left-color: #1f6feb;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {\n  color: #4493f8;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important {\n  border-left-color: #8957e5;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {\n  color: #ab7df8;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning {\n  border-left-color: #9e6a03;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {\n  color: #d29922;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip {\n  border-left-color: #238636;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {\n  color: #3fb950;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution {\n  border-left-color: #da3633;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {\n  color: #f85149;\n}\n\n.markdown-body>*:first-child>.heading-element:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body .highlight pre:has(+.zeroclipboard-container) {\n  min-height: 52px;\n}\n\n"
  },
  {
    "path": "public/markdown/github-markdown-light.css",
    "content": "/* light */\n.markdown-body {\n  color-scheme: light;\n  -ms-text-size-adjust: 100%;\n  -webkit-text-size-adjust: 100%;\n  margin: 0;\n  color: #1f2328;\n  background-color: #ffffff;\n  font-family: -apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Noto Sans\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";\n  font-size: 16px;\n  line-height: 1.5;\n  word-wrap: break-word;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  fill: currentColor;\n  vertical-align: text-bottom;\n}\n\n.markdown-body h1:hover .anchor .octicon-link:before,\n.markdown-body h2:hover .anchor .octicon-link:before,\n.markdown-body h3:hover .anchor .octicon-link:before,\n.markdown-body h4:hover .anchor .octicon-link:before,\n.markdown-body h5:hover .anchor .octicon-link:before,\n.markdown-body h6:hover .anchor .octicon-link:before {\n  width: 16px;\n  height: 16px;\n  content: ' ';\n  display: inline-block;\n  background-color: currentColor;\n  -webkit-mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n  mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n}\n\n.markdown-body details,\n.markdown-body figcaption,\n.markdown-body figure {\n  display: block;\n}\n\n.markdown-body summary {\n  display: list-item;\n}\n\n.markdown-body [hidden] {\n  display: none !important;\n}\n\n.markdown-body a {\n  background-color: transparent;\n  color: #0969da;\n  text-decoration: none;\n}\n\n.markdown-body abbr[title] {\n  border-bottom: none;\n  -webkit-text-decoration: underline dotted;\n  text-decoration: underline dotted;\n}\n\n.markdown-body b,\n.markdown-body strong {\n  font-weight: 600;\n}\n\n.markdown-body dfn {\n  font-style: italic;\n}\n\n.markdown-body h1 {\n  margin: .67em 0;\n  font-weight: 600;\n  padding-bottom: .3em;\n  font-size: 2em;\n  border-bottom: 1px solid #d1d9e0b3;\n}\n\n.markdown-body mark {\n  background-color: #fff8c5;\n  color: #1f2328;\n}\n\n.markdown-body small {\n  font-size: 90%;\n}\n\n.markdown-body sub,\n.markdown-body sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.markdown-body sub {\n  bottom: -0.25em;\n}\n\n.markdown-body sup {\n  top: -0.5em;\n}\n\n.markdown-body img {\n  border-style: none;\n  max-width: 100%;\n  box-sizing: content-box;\n}\n\n.markdown-body code,\n.markdown-body kbd,\n.markdown-body pre,\n.markdown-body samp {\n  font-family: monospace;\n  font-size: 1em;\n}\n\n.markdown-body figure {\n  margin: 1em 2.5rem;\n}\n\n.markdown-body hr {\n  box-sizing: content-box;\n  overflow: hidden;\n  background: transparent;\n  border-bottom: 1px solid #d1d9e0b3;\n  height: .25em;\n  padding: 0;\n  margin: 1.5rem 0;\n  background-color: #d1d9e0;\n  border: 0;\n}\n\n.markdown-body input {\n  font: inherit;\n  margin: 0;\n  overflow: visible;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.markdown-body [type=button],\n.markdown-body [type=reset],\n.markdown-body [type=submit] {\n  -webkit-appearance: button;\n  appearance: button;\n}\n\n.markdown-body [type=checkbox],\n.markdown-body [type=radio] {\n  box-sizing: border-box;\n  padding: 0;\n}\n\n.markdown-body [type=number]::-webkit-inner-spin-button,\n.markdown-body [type=number]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.markdown-body [type=search]::-webkit-search-cancel-button,\n.markdown-body [type=search]::-webkit-search-decoration {\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-body ::-webkit-input-placeholder {\n  color: inherit;\n  opacity: .54;\n}\n\n.markdown-body ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  appearance: button;\n  font: inherit;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body ::placeholder {\n  color: #59636e;\n  opacity: 1;\n}\n\n.markdown-body hr::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body hr::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n  font-variant: tabular-nums;\n}\n\n.markdown-body td,\n.markdown-body th {\n  padding: 0;\n}\n\n.markdown-body details summary {\n  cursor: pointer;\n}\n\n.markdown-body a:focus,\n.markdown-body [role=button]:focus,\n.markdown-body input[type=radio]:focus,\n.markdown-body input[type=checkbox]:focus {\n  outline: 2px solid #0969da;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:focus:not(:focus-visible),\n.markdown-body [role=button]:focus:not(:focus-visible),\n.markdown-body input[type=radio]:focus:not(:focus-visible),\n.markdown-body input[type=checkbox]:focus:not(:focus-visible) {\n  outline: solid 1px transparent;\n}\n\n.markdown-body a:focus-visible,\n.markdown-body [role=button]:focus-visible,\n.markdown-body input[type=radio]:focus-visible,\n.markdown-body input[type=checkbox]:focus-visible {\n  outline: 2px solid #0969da;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:not([class]):focus,\n.markdown-body a:not([class]):focus-visible,\n.markdown-body input[type=radio]:focus,\n.markdown-body input[type=radio]:focus-visible,\n.markdown-body input[type=checkbox]:focus,\n.markdown-body input[type=checkbox]:focus-visible {\n  outline-offset: 0;\n}\n\n.markdown-body kbd {\n  display: inline-block;\n  padding: 0.25rem;\n  font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  line-height: 10px;\n  color: #1f2328;\n  vertical-align: middle;\n  background-color: #f6f8fa;\n  border: solid 1px #d1d9e0b3;\n  border-bottom-color: #d1d9e0b3;\n  border-radius: 6px;\n  box-shadow: inset 0 -1px 0 #d1d9e0b3;\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  margin-top: 1.5rem;\n  margin-bottom: 1rem;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.markdown-body h2 {\n  font-weight: 600;\n  padding-bottom: .3em;\n  font-size: 1.5em;\n  border-bottom: 1px solid #d1d9e0b3;\n}\n\n.markdown-body h3 {\n  font-weight: 600;\n  font-size: 1.25em;\n}\n\n.markdown-body h4 {\n  font-weight: 600;\n  font-size: 1em;\n}\n\n.markdown-body h5 {\n  font-weight: 600;\n  font-size: .875em;\n}\n\n.markdown-body h6 {\n  font-weight: 600;\n  font-size: .85em;\n  color: #59636e;\n}\n\n.markdown-body p {\n  margin-top: 0;\n  margin-bottom: 10px;\n}\n\n.markdown-body blockquote {\n  margin: 0;\n  padding: 0 1em;\n  color: #59636e;\n  border-left: .25em solid #d1d9e0;\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  margin-top: 0;\n  margin-bottom: 0;\n  padding-left: 2em;\n}\n\n.markdown-body ol ol,\n.markdown-body ul ol {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ul ul ol,\n.markdown-body ul ol ol,\n.markdown-body ol ul ol,\n.markdown-body ol ol ol {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body dd {\n  margin-left: 0;\n}\n\n.markdown-body tt,\n.markdown-body code,\n.markdown-body samp {\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  font-size: 12px;\n}\n\n.markdown-body pre {\n  margin-top: 0;\n  margin-bottom: 0;\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;\n  font-size: 12px;\n  word-wrap: normal;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  overflow: visible !important;\n  vertical-align: text-bottom;\n  fill: currentColor;\n}\n\n.markdown-body input::-webkit-outer-spin-button,\n.markdown-body input::-webkit-inner-spin-button {\n  margin: 0;\n  appearance: none;\n}\n\n.markdown-body .mr-2 {\n  margin-right: 0.5rem !important;\n}\n\n.markdown-body::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body>*:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body>*:last-child {\n  margin-bottom: 0 !important;\n}\n\n.markdown-body a:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\n.markdown-body .absent {\n  color: #d1242f;\n}\n\n.markdown-body .anchor {\n  float: left;\n  padding-right: 0.25rem;\n  margin-left: -20px;\n  line-height: 1;\n}\n\n.markdown-body .anchor:focus {\n  outline: none;\n}\n\n.markdown-body p,\n.markdown-body blockquote,\n.markdown-body ul,\n.markdown-body ol,\n.markdown-body dl,\n.markdown-body table,\n.markdown-body pre,\n.markdown-body details {\n  margin-top: 0;\n  margin-bottom: 1rem;\n}\n\n.markdown-body blockquote>:first-child {\n  margin-top: 0;\n}\n\n.markdown-body blockquote>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body h1 .octicon-link,\n.markdown-body h2 .octicon-link,\n.markdown-body h3 .octicon-link,\n.markdown-body h4 .octicon-link,\n.markdown-body h5 .octicon-link,\n.markdown-body h6 .octicon-link {\n  color: #1f2328;\n  vertical-align: middle;\n  visibility: hidden;\n}\n\n.markdown-body h1:hover .anchor,\n.markdown-body h2:hover .anchor,\n.markdown-body h3:hover .anchor,\n.markdown-body h4:hover .anchor,\n.markdown-body h5:hover .anchor,\n.markdown-body h6:hover .anchor {\n  text-decoration: none;\n}\n\n.markdown-body h1:hover .anchor .octicon-link,\n.markdown-body h2:hover .anchor .octicon-link,\n.markdown-body h3:hover .anchor .octicon-link,\n.markdown-body h4:hover .anchor .octicon-link,\n.markdown-body h5:hover .anchor .octicon-link,\n.markdown-body h6:hover .anchor .octicon-link {\n  visibility: visible;\n}\n\n.markdown-body h1 tt,\n.markdown-body h1 code,\n.markdown-body h2 tt,\n.markdown-body h2 code,\n.markdown-body h3 tt,\n.markdown-body h3 code,\n.markdown-body h4 tt,\n.markdown-body h4 code,\n.markdown-body h5 tt,\n.markdown-body h5 code,\n.markdown-body h6 tt,\n.markdown-body h6 code {\n  padding: 0 .2em;\n  font-size: inherit;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2,\n.markdown-body summary h3,\n.markdown-body summary h4,\n.markdown-body summary h5,\n.markdown-body summary h6 {\n  display: inline-block;\n}\n\n.markdown-body summary h1 .anchor,\n.markdown-body summary h2 .anchor,\n.markdown-body summary h3 .anchor,\n.markdown-body summary h4 .anchor,\n.markdown-body summary h5 .anchor,\n.markdown-body summary h6 .anchor {\n  margin-left: -40px;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2 {\n  padding-bottom: 0;\n  border-bottom: 0;\n}\n\n.markdown-body ul.no-list,\n.markdown-body ol.no-list {\n  padding: 0;\n  list-style-type: none;\n}\n\n.markdown-body ol[type=\"a s\"] {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body ol[type=\"A s\"] {\n  list-style-type: upper-alpha;\n}\n\n.markdown-body ol[type=\"i s\"] {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ol[type=\"I s\"] {\n  list-style-type: upper-roman;\n}\n\n.markdown-body ol[type=\"1\"] {\n  list-style-type: decimal;\n}\n\n.markdown-body div>ol:not([type]) {\n  list-style-type: decimal;\n}\n\n.markdown-body ul ul,\n.markdown-body ul ol,\n.markdown-body ol ol,\n.markdown-body ol ul {\n  margin-top: 0;\n  margin-bottom: 0;\n}\n\n.markdown-body li>p {\n  margin-top: 1rem;\n}\n\n.markdown-body li+li {\n  margin-top: .25em;\n}\n\n.markdown-body dl {\n  padding: 0;\n}\n\n.markdown-body dl dt {\n  padding: 0;\n  margin-top: 1rem;\n  font-size: 1em;\n  font-style: italic;\n  font-weight: 600;\n}\n\n.markdown-body dl dd {\n  padding: 0 1rem;\n  margin-bottom: 1rem;\n}\n\n.markdown-body table th {\n  font-weight: 600;\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  padding: 6px 13px;\n  border: 1px solid #d1d9e0;\n}\n\n.markdown-body table td>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body table tr {\n  background-color: #ffffff;\n  border-top: 1px solid #d1d9e0b3;\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: #f6f8fa;\n}\n\n.markdown-body table img {\n  background-color: transparent;\n}\n\n.markdown-body img[align=right] {\n  padding-left: 20px;\n}\n\n.markdown-body img[align=left] {\n  padding-right: 20px;\n}\n\n.markdown-body .emoji {\n  max-width: none;\n  vertical-align: text-top;\n  background-color: transparent;\n}\n\n.markdown-body span.frame {\n  display: block;\n  overflow: hidden;\n}\n\n.markdown-body span.frame>span {\n  display: block;\n  float: left;\n  width: auto;\n  padding: 7px;\n  margin: 13px 0 0;\n  overflow: hidden;\n  border: 1px solid #d1d9e0;\n}\n\n.markdown-body span.frame span img {\n  display: block;\n  float: left;\n}\n\n.markdown-body span.frame span span {\n  display: block;\n  padding: 5px 0 0;\n  clear: both;\n  color: #1f2328;\n}\n\n.markdown-body span.align-center {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-center>span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: center;\n}\n\n.markdown-body span.align-center span img {\n  margin: 0 auto;\n  text-align: center;\n}\n\n.markdown-body span.align-right {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-right>span {\n  display: block;\n  margin: 13px 0 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body span.align-right span img {\n  margin: 0;\n  text-align: right;\n}\n\n.markdown-body span.float-left {\n  display: block;\n  float: left;\n  margin-right: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-left span {\n  margin: 13px 0 0;\n}\n\n.markdown-body span.float-right {\n  display: block;\n  float: right;\n  margin-left: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-right>span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body code,\n.markdown-body tt {\n  padding: .2em .4em;\n  margin: 0;\n  font-size: 85%;\n  white-space: break-spaces;\n  background-color: #818b981f;\n  border-radius: 6px;\n}\n\n.markdown-body code br,\n.markdown-body tt br {\n  display: none;\n}\n\n.markdown-body del code {\n  text-decoration: inherit;\n}\n\n.markdown-body samp {\n  font-size: 85%;\n}\n\n.markdown-body pre code {\n  font-size: 100%;\n}\n\n.markdown-body pre>code {\n  padding: 0;\n  margin: 0;\n  word-break: normal;\n  white-space: pre;\n  background: transparent;\n  border: 0;\n}\n\n.markdown-body .highlight {\n  margin-bottom: 1rem;\n}\n\n.markdown-body .highlight pre {\n  margin-bottom: 0;\n  word-break: normal;\n}\n\n.markdown-body .highlight pre,\n.markdown-body pre {\n  padding: 1rem;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  color: #1f2328;\n  background-color: #f6f8fa;\n  border-radius: 6px;\n}\n\n.markdown-body pre code,\n.markdown-body pre tt {\n  display: inline;\n  max-width: auto;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-body .csv-data td,\n.markdown-body .csv-data th {\n  padding: 5px;\n  overflow: hidden;\n  font-size: 12px;\n  line-height: 1;\n  text-align: left;\n  white-space: nowrap;\n}\n\n.markdown-body .csv-data .blob-num {\n  padding: 10px 0.5rem 9px;\n  text-align: right;\n  background: #ffffff;\n  border: 0;\n}\n\n.markdown-body .csv-data tr {\n  border-top: 0;\n}\n\n.markdown-body .csv-data th {\n  font-weight: 600;\n  background: #f6f8fa;\n  border-top: 0;\n}\n\n.markdown-body [data-footnote-ref]::before {\n  content: \"[\";\n}\n\n.markdown-body [data-footnote-ref]::after {\n  content: \"]\";\n}\n\n.markdown-body .footnotes {\n  font-size: 12px;\n  color: #59636e;\n  border-top: 1px solid #d1d9e0;\n}\n\n.markdown-body .footnotes ol {\n  padding-left: 1rem;\n}\n\n.markdown-body .footnotes ol ul {\n  display: inline-block;\n  padding-left: 1rem;\n  margin-top: 1rem;\n}\n\n.markdown-body .footnotes li {\n  position: relative;\n}\n\n.markdown-body .footnotes li:target::before {\n  position: absolute;\n  top: calc(0.5rem*-1);\n  right: calc(0.5rem*-1);\n  bottom: calc(0.5rem*-1);\n  left: calc(1.5rem*-1);\n  pointer-events: none;\n  content: \"\";\n  border: 2px solid #0969da;\n  border-radius: 6px;\n}\n\n.markdown-body .footnotes li:target {\n  color: #1f2328;\n}\n\n.markdown-body .footnotes .data-footnote-backref g-emoji {\n  font-family: monospace;\n}\n\n.markdown-body body:has(:modal) {\n  padding-right: var(--dialog-scrollgutter) !important;\n}\n\n.markdown-body .pl-c {\n  color: #59636e;\n}\n\n.markdown-body .pl-c1,\n.markdown-body .pl-s .pl-v {\n  color: #0550ae;\n}\n\n.markdown-body .pl-e,\n.markdown-body .pl-en {\n  color: #6639ba;\n}\n\n.markdown-body .pl-smi,\n.markdown-body .pl-s .pl-s1 {\n  color: #1f2328;\n}\n\n.markdown-body .pl-ent {\n  color: #0550ae;\n}\n\n.markdown-body .pl-k {\n  color: #cf222e;\n}\n\n.markdown-body .pl-s,\n.markdown-body .pl-pds,\n.markdown-body .pl-s .pl-pse .pl-s1,\n.markdown-body .pl-sr,\n.markdown-body .pl-sr .pl-cce,\n.markdown-body .pl-sr .pl-sre,\n.markdown-body .pl-sr .pl-sra {\n  color: #0a3069;\n}\n\n.markdown-body .pl-v,\n.markdown-body .pl-smw {\n  color: #953800;\n}\n\n.markdown-body .pl-bu {\n  color: #82071e;\n}\n\n.markdown-body .pl-ii {\n  color: #f6f8fa;\n  background-color: #82071e;\n}\n\n.markdown-body .pl-c2 {\n  color: #f6f8fa;\n  background-color: #cf222e;\n}\n\n.markdown-body .pl-sr .pl-cce {\n  font-weight: bold;\n  color: #116329;\n}\n\n.markdown-body .pl-ml {\n  color: #3b2300;\n}\n\n.markdown-body .pl-mh,\n.markdown-body .pl-mh .pl-en,\n.markdown-body .pl-ms {\n  font-weight: bold;\n  color: #0550ae;\n}\n\n.markdown-body .pl-mi {\n  font-style: italic;\n  color: #1f2328;\n}\n\n.markdown-body .pl-mb {\n  font-weight: bold;\n  color: #1f2328;\n}\n\n.markdown-body .pl-md {\n  color: #82071e;\n  background-color: #ffebe9;\n}\n\n.markdown-body .pl-mi1 {\n  color: #116329;\n  background-color: #dafbe1;\n}\n\n.markdown-body .pl-mc {\n  color: #953800;\n  background-color: #ffd8b5;\n}\n\n.markdown-body .pl-mi2 {\n  color: #d1d9e0;\n  background-color: #0550ae;\n}\n\n.markdown-body .pl-mdr {\n  font-weight: bold;\n  color: #8250df;\n}\n\n.markdown-body .pl-ba {\n  color: #59636e;\n}\n\n.markdown-body .pl-sg {\n  color: #818b98;\n}\n\n.markdown-body .pl-corl {\n  text-decoration: underline;\n  color: #0a3069;\n}\n\n.markdown-body [role=button]:focus:not(:focus-visible),\n.markdown-body [role=tabpanel][tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body button:focus:not(:focus-visible),\n.markdown-body summary:focus:not(:focus-visible),\n.markdown-body a:focus:not(:focus-visible) {\n  outline: none;\n  box-shadow: none;\n}\n\n.markdown-body [tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body details-dialog:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.markdown-body g-emoji {\n  display: inline-block;\n  min-width: 1ch;\n  font-family: \"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\";\n  font-size: 1em;\n  font-style: normal !important;\n  font-weight: 400;\n  line-height: 1;\n  vertical-align: -0.075em;\n}\n\n.markdown-body g-emoji img {\n  width: 1em;\n  height: 1em;\n}\n\n.markdown-body .task-list-item {\n  list-style-type: none;\n}\n\n.markdown-body .task-list-item label {\n  font-weight: 400;\n}\n\n.markdown-body .task-list-item.enabled label {\n  cursor: pointer;\n}\n\n.markdown-body .task-list-item+.task-list-item {\n  margin-top: 0.25rem;\n}\n\n.markdown-body .task-list-item .handle {\n  display: none;\n}\n\n.markdown-body .task-list-item-checkbox {\n  margin: 0 .2em .25em -1.4em;\n  vertical-align: middle;\n}\n\n.markdown-body ul:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em .25em .2em;\n}\n\n.markdown-body ol:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em .25em .2em;\n}\n\n.markdown-body .contains-task-list:hover .task-list-item-convert-container,\n.markdown-body .contains-task-list:focus-within .task-list-item-convert-container {\n  display: block;\n  width: auto;\n  height: 24px;\n  overflow: visible;\n  clip: auto;\n}\n\n.markdown-body ::-webkit-calendar-picker-indicator {\n  filter: invert(50%);\n}\n\n.markdown-body .markdown-alert {\n  padding: 0.5rem 1rem;\n  margin-bottom: 1rem;\n  color: inherit;\n  border-left: .25em solid #d1d9e0;\n}\n\n.markdown-body .markdown-alert>:first-child {\n  margin-top: 0;\n}\n\n.markdown-body .markdown-alert>:last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body .markdown-alert .markdown-alert-title {\n  display: flex;\n  font-weight: 500;\n  align-items: center;\n  line-height: 1;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note {\n  border-left-color: #0969da;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {\n  color: #0969da;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important {\n  border-left-color: #8250df;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {\n  color: #8250df;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning {\n  border-left-color: #9a6700;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {\n  color: #9a6700;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip {\n  border-left-color: #1a7f37;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {\n  color: #1a7f37;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution {\n  border-left-color: #cf222e;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {\n  color: #d1242f;\n}\n\n.markdown-body>*:first-child>.heading-element:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body .highlight pre:has(+.zeroclipboard-container) {\n  min-height: 52px;\n}\n\n"
  },
  {
    "path": "scripts/sync-version.sh",
    "content": "#!/bin/bash\n\n# 同步版本号脚本\n# 从 tauri.conf.json 读取版本号并更新 iOS Info.plist\n\n# 获取当前版本号\nVERSION=$(node -p \"require('./src-tauri/tauri.conf.json').version\")\n\necho \"同步版本号: $VERSION\"\n\n# 更新 iOS Info.plist\nPLIST_PATH=\"src-tauri/gen/apple/note-gen_iOS/Info.plist\"\n\nif [ -f \"$PLIST_PATH\" ]; then\n    # 更新版本号 - 使用更精确的匹配模式\n    sed -i '' '/CFBundleShortVersionString/,/<string>/s/<string>.*<\\/string>/<string>'$VERSION'<\\/string>/' \"$PLIST_PATH\"\n    sed -i '' '/CFBundleVersion/,/<string>/s/<string>.*<\\/string>/<string>'$VERSION'<\\/string>/' \"$PLIST_PATH\"\n    \n    echo \"iOS 版本号已更新为: $VERSION\"\nelse\n    echo \"Info.plist 文件不存在，请先运行构建命令\"\nfi\n"
  },
  {
    "path": "src/app/core/index.d.ts",
    "content": "declare module \"note-gen/screenshot\" {\n    export interface ScreenshotImage {\n        name: string;\n        path: string;\n        width: number;\n        height: number;\n        x: number;\n        y: number;\n        z: number;\n    }\n}"
  },
  {
    "path": "src/app/core/layout.tsx",
    "content": "'use client'\n\nimport { ThemeProvider } from \"@/components/theme-provider\"\nimport useSettingStore from \"@/stores/setting\"\nimport { useEffect, useState } from \"react\";\nimport { initAllDatabases } from \"@/db\"\nimport dayjs from \"dayjs\"\nimport zh from \"dayjs/locale/zh-cn\";\nimport en from \"dayjs/locale/en\";\nimport { useI18n } from \"@/hooks/useI18n\"\nimport useVectorStore from \"@/stores/vector\"\nimport useImageStore from \"@/stores/imageHosting\"\nimport useShortcutStore from \"@/stores/shortcut\"\nimport useUpdateStore from \"@/stores/update\"\nimport initQuickRecordText from \"@/lib/shortcut/quick-record-text\"\nimport { useRouter, usePathname } from \"next/navigation\"\nimport initShowWindow from \"@/lib/shortcut/show-window\"\nimport { initMcp } from \"@/lib/mcp/init\"\nimport { SearchDialog } from \"@/components/search-dialog\"\nimport { ActivityDrawer } from \"@/components/activity/activity-drawer\"\nimport { reportAppStart } from \"@/lib/event-report\"\nimport { TitleBar } from \"@/components/title-bar\"\nimport { Store } from '@tauri-apps/plugin-store'\nimport { TextSizeProvider } from \"@/contexts/text-size-context\"\nimport { SyncConfirmDialog } from \"@/components/sync-confirm-dialog\"\nimport { applyThemeColors } from \"@/lib/theme-utils\"\nimport emitter from \"@/lib/emitter\"\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  const { initSettingData, uiScale, customThemeColors } = useSettingStore()\n  const { initMainHosting } = useImageStore()\n  const { currentLocale } = useI18n()\n  const { initShortcut } = useShortcutStore()\n  const { initVectorDb } = useVectorStore()\n  const { initUpdateStore, checkForUpdates } = useUpdateStore()\n  const router = useRouter()\n  const pathname = usePathname()\n  const [searchOpen, setSearchOpen] = useState(false)\n  const [activityOpen, setActivityOpen] = useState(false)\n\n  // 重定向旧路径到新的 /core/main\n  useEffect(() => {\n    async function redirectOldPaths() {\n      if (pathname === '/core/article' || pathname === '/core/record') {\n        const store = await Store.load('store.json')\n        await store.set('currentPage', '/core/main')\n        await store.save()\n        router.replace('/core/main')\n      }\n    }\n    redirectOldPaths()\n  }, [pathname, router])\n\n  useEffect(() => {\n    let cancelled = false\n\n    const initializeApp = async () => {\n      try {\n        initSettingData()\n        initMainHosting()\n\n        // 先完成数据库和默认工作区初始化，避免首次启动时其他逻辑抢先读取空目录或未建表数据库。\n        await initAllDatabases()\n        if (cancelled) return\n\n        initShortcut()\n        await initVectorDb()\n        if (cancelled) return\n\n        initQuickRecordText()\n        initShowWindow()\n        initMcp()\n        reportAppStart()\n\n        await initUpdateStore()\n        if (cancelled) return\n        checkForUpdates()\n      } catch (error) {\n        console.error('Failed to initialize app core:', error)\n      }\n    }\n\n    void initializeApp()\n\n    return () => {\n      cancelled = true\n    }\n  }, [])\n\n  // 应用界面缩放\n  useEffect(() => {\n    if (uiScale && uiScale !== 100) {\n      document.documentElement.style.fontSize = `${uiScale}%`\n    }\n  }, [uiScale])\n\n  // 应用自定义主题颜色\n  useEffect(() => {\n    applyThemeColors(customThemeColors)\n  }, [customThemeColors])\n\n  useEffect(() => {\n    switch (currentLocale) {\n      case 'zh':\n        dayjs.locale(zh);\n        break;\n      case 'en':\n        dayjs.locale(en);\n        break;\n      default:\n        break;\n    }\n  }, [currentLocale])\n\n  // 禁用浏览器后退快捷键（Backspace）和添加搜索快捷键（Cmd/Ctrl+F）\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // 搜索快捷键：Cmd+F (macOS) 或 Ctrl+F (Windows/Linux)\n      if ((e.metaKey || e.ctrlKey) && e.key === 'f') {\n        // 检查焦点是否在编辑器内\n        const target = e.target as HTMLElement\n        const editorElement = document.getElementById('aritcle-md-editor')\n        const isFocusInEditor = editorElement && editorElement.contains(target)\n\n        // 如果焦点在编辑器内，触发编辑器搜索\n        if (isFocusInEditor) {\n          e.preventDefault()\n          // 触发编辑器内搜索\n          emitter.emit('editor-search-trigger' as any)\n          return\n        }\n\n        // 否则打开全局搜索\n        e.preventDefault()\n        setSearchOpen(true)\n        return\n      }\n\n      // 如果按下 Backspace 键，且不在可编辑元素中\n      if (e.key === 'Backspace') {\n        const target = e.target as HTMLElement\n        const isEditable =\n          target.tagName === 'INPUT' ||\n          target.tagName === 'TEXTAREA' ||\n          target.isContentEditable ||\n          target.getAttribute('contenteditable') === 'true'\n\n        // 如果在可编辑元素中，允许正常删除\n        if (isEditable) {\n          return\n        }\n\n        // 否则阻止默认的后退行为\n        e.preventDefault()\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown)\n    }\n  }, [])\n\n  return (\n    <ThemeProvider\n      attribute=\"class\"\n      defaultTheme=\"system\"\n      enableSystem\n      disableTransitionOnChange\n    >\n      <TextSizeProvider>\n        <TitleBar\n          onSearchClick={() => setSearchOpen(true)}\n          onActivityClick={() => setActivityOpen(open => !open)}\n          activityOpen={activityOpen}\n        />\n        <main className=\"flex flex-1 flex-col overflow-hidden w-full h-[calc(100vh-36px)] mt-9\">\n          {children}\n        </main>\n        <ActivityDrawer open={activityOpen} onOpenChange={setActivityOpen} />\n        <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />\n        <SyncConfirmDialog />\n      </TextSizeProvider>\n    </ThemeProvider>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/chat/agent-execution-status.tsx",
    "content": "import * as React from \"react\"\nimport useChatStore from \"@/stores/chat\"\nimport { AgentPanelWithRag } from \"./agent-panel-with-rag\"\n\n/**\n * Agent execution status component - displays real-time agent execution state\n * This component uses AgentPanelWithRag to show both RAG sources and Agent steps together\n */\nexport function AgentExecutionStatus() {\n  const {\n    agentState,\n    setAgentState,\n    currentConversationId,\n    setAgentAutoApproveConversationId,\n    setAgentAutoApproveRuntimeSkillId,\n  } = useChatStore()\n\n  // Handle confirmation\n  const handleConfirm = (scope: 'once' | 'conversation' = 'once') => {\n    if (!agentState.pendingConfirmation) return\n\n    const confirmationRecord = {\n      toolName: agentState.pendingConfirmation.toolName,\n      params: agentState.pendingConfirmation.params,\n      status: 'confirmed' as const,\n      timestamp: Date.now(),\n      scope,\n      sessionApprovalType: agentState.pendingConfirmation.sessionApprovalType,\n      sessionApprovalSkillId: agentState.pendingConfirmation.sessionApprovalSkillId,\n    }\n\n    if (scope === 'conversation' && currentConversationId !== null) {\n      setAgentAutoApproveConversationId(currentConversationId)\n      setAgentAutoApproveRuntimeSkillId(\n        agentState.pendingConfirmation.sessionApprovalType === 'runtime-script-skill'\n          ? agentState.pendingConfirmation.sessionApprovalSkillId || null\n          : null\n      )\n    }\n\n    // Confirm while keeping isRunning: true, only clear pendingConfirmation\n    setAgentState({\n      pendingConfirmation: undefined,\n      confirmationHistory: [...agentState.confirmationHistory, confirmationRecord],\n      isRunning: true  // Explicitly keep running state\n    })\n  }\n\n  const handleCancel = () => {\n    if (!agentState.pendingConfirmation) return\n\n    const confirmationRecord = {\n      toolName: agentState.pendingConfirmation.toolName,\n      params: agentState.pendingConfirmation.params,\n      status: 'cancelled' as const,\n      timestamp: Date.now()\n    }\n\n    // Cancel and stop agent execution\n    setAgentState({\n      pendingConfirmation: undefined,\n      confirmationHistory: [...agentState.confirmationHistory, confirmationRecord],\n      isRunning: false\n    })\n  }\n\n  return (\n    <AgentPanelWithRag\n      ragSources={agentState.ragSources || []}\n      ragSourceDetails={agentState.ragSourceDetails || []}\n      isRunning={agentState.isRunning}\n      isThinking={agentState.isThinking}\n      currentThought={agentState.currentThought}\n      thoughtHistory={agentState.thoughtHistory}\n      completedSteps={agentState.completedSteps}\n      currentAction={agentState.currentAction}\n      currentObservation={agentState.currentObservation}\n      toolCalls={agentState.toolCalls}\n      pendingConfirmation={agentState.pendingConfirmation}\n      confirmationHistory={agentState.confirmationHistory}\n      currentStepStartTime={agentState.currentStepStartTime}\n      onConfirm={handleConfirm}\n      onCancel={handleCancel}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/agent-history.tsx",
    "content": "import * as React from \"react\"\nimport { AgentPlan } from \"@/components/ui/agent-plan\"\n\ninterface AgentHistoryProps {\n  historyJson: string\n}\n\n/**\n * Agent history component - displays saved agent execution history\n * This component now uses the unified AgentPlan component for consistent styling\n */\nexport function AgentHistory({ historyJson }: AgentHistoryProps) {\n  return (\n    <AgentPlan\n      mode=\"history\"\n      historyJson={historyJson}\n    />\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/agent-panel-with-rag.tsx",
    "content": "\"use client\"\nimport * as React from \"react\"\nimport { AgentPlan } from \"@/components/ui/agent-plan\"\nimport { FileText, ChevronRight, Database, ExternalLink } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport useArticleStore from \"@/stores/article\"\n\ninterface RagSourceDetail {\n  filepath: string\n  filename: string\n  content: string\n}\n\ninterface AgentPanelWithRagProps {\n  // RAG 相关\n  ragSources?: string[]\n  ragSourceDetails?: RagSourceDetail[]\n\n  // Agent 历史模式\n  agentHistoryJson?: string\n\n  // Agent 实时模式（如果需要）\n  isRunning?: boolean\n  isThinking?: boolean\n  currentThought?: string\n  thoughtHistory?: string[]\n  completedSteps?: Array<{\n    thought: string\n    action?: { tool: string; params: Record<string, any> }\n    observation?: string\n    duration?: number\n  }>\n  currentAction?: string\n  currentObservation?: string\n  toolCalls?: Array<{\n    id: string\n    toolName: string\n    params: Record<string, any>\n    result?: { success: boolean; message?: string; data?: any; error?: string }\n    status: \"pending\" | \"running\" | \"success\" | \"error\"\n    timestamp: number\n  }>\n  pendingConfirmation?: {\n    toolName: string\n    params: Record<string, any>\n    originalContent?: string\n    modifiedContent?: string\n    filePath?: string\n    canApproveForSession?: boolean\n    sessionApprovalType?: \"write\" | \"runtime-script-skill\"\n    sessionApprovalSkillId?: string\n  }\n  confirmationHistory?: Array<{\n    toolName: string\n    params: Record<string, any>\n    status: \"pending\" | \"confirmed\" | \"cancelled\"\n    timestamp: number\n    scope?: \"once\" | \"conversation\"\n    sessionApprovalType?: \"write\" | \"runtime-script-skill\"\n    sessionApprovalSkillId?: string\n  }>\n  currentStepStartTime?: number\n  onConfirm?: (scope?: \"once\" | \"conversation\") => void\n  onCancel?: () => void\n}\n\n/**\n * Agent 面板组件 - 将知识库检索和 Agent 执行合并在一起\n */\nexport function AgentPanelWithRag({\n  ragSources = [],\n  ragSourceDetails = [],\n  agentHistoryJson,\n  isRunning = false,\n  isThinking = false,\n  currentThought = \"\",\n  thoughtHistory = [],\n  completedSteps = [],\n  currentAction = \"\",\n  currentObservation = \"\",\n  toolCalls = [],\n  pendingConfirmation,\n  confirmationHistory = [],\n  currentStepStartTime,\n  onConfirm,\n  onCancel,\n}: AgentPanelWithRagProps) {\n  const t = useTranslations()\n  const [isRagExpanded, setIsRagExpanded] = useState(false)\n  const [expandedFiles, setExpandedFiles] = useState<string[]>([])\n  const { setActiveFilePath, readArticle } = useArticleStore()\n\n  // 创建文件名到详情的映射\n  const detailMap = React.useMemo(\n    () => new Map(ragSourceDetails.map((d) => [d.filename, d])),\n    [ragSourceDetails]\n  )\n\n  // 打开文件\n  const handleOpenFile = (e: React.MouseEvent, filepath: string) => {\n    e.stopPropagation()\n    setActiveFilePath(filepath)\n    readArticle(filepath)\n  }\n\n  // 切换单个文件的展开状态\n  const toggleFileExpansion = (filename: string) => {\n    setExpandedFiles((prev) =>\n      prev.includes(filename)\n        ? prev.filter((f) => f !== filename)\n        : [...prev, filename]\n    )\n  }\n\n  // 确定模式：如果有 agentHistoryJson，使用历史模式；否则使用实时模式\n  const mode: \"live\" | \"history\" = agentHistoryJson ? \"history\" : \"live\"\n\n  // 如果既没有 RAG 也没有 Agent 内容，不渲染\n  const hasRag = ragSources.length > 0\n  const hasAgent = agentHistoryJson || isRunning || thoughtHistory.length > 0\n\n  if (!hasRag && !hasAgent) {\n    return null\n  }\n\n  return (\n    <div className=\"w-full\">\n      <div className=\"overflow-hidden\">\n        <ul className=\"space-y-2\">\n          {/* 知识库检索步骤 */}\n          {hasRag && (\n            <>\n              <li>\n                <div\n                  className=\"group flex items-center gap-2 py-2 cursor-pointer\"\n                  onClick={() => setIsRagExpanded(!isRagExpanded)}\n                >\n                  <div className=\"shrink-0\">\n                    <Database className=\"size-4.5 text-blue-500\" />\n                  </div>\n                  <div className=\"flex min-w-0 grow items-center justify-between\">\n                    <span className=\"text-sm\">\n                      {t(\"record.chat.ragSources.label\", { count: ragSources.length })}\n                    </span>\n                    <ChevronRight\n                      className={`size-4 text-muted-foreground shrink-0 transition-transform ${\n                        isRagExpanded ? \"rotate-90\" : \"\"\n                      }`}\n                    />\n                  </div>\n                </div>\n              </li>\n\n              {/* 文件列表 */}\n              {isRagExpanded && ragSources.map((source) => {\n                const hasDetail = detailMap.has(source)\n                const detail = detailMap.get(source)\n                const isFileExpanded = expandedFiles.includes(source)\n\n                return (\n                  <li key={source} className=\"mt-1\">\n                    <div\n                      className=\"group flex items-center gap-2 py-1 cursor-pointer\"\n                      onClick={() => hasDetail && toggleFileExpansion(source)}\n                    >\n                      <div className=\"shrink-0\">\n                        <div className=\"size-4.5\" />\n                      </div>\n                      <div className=\"shrink-0\">\n                        <FileText className=\"size-4 text-muted-foreground\" />\n                      </div>\n                      <div className=\"flex min-w-0 grow items-center justify-between gap-2\">\n                        <span\n                          className={`truncate text-sm ${\n                            hasDetail\n                              ? \"text-foreground group-hover:text-primary transition-colors\"\n                              : \"text-muted-foreground\"\n                          }`}\n                        >\n                          {source}\n                        </span>\n                        {hasDetail && (\n                          <ChevronRight\n                            className={`size-4 text-muted-foreground shrink-0 transition-transform ${\n                              isFileExpanded ? \"rotate-90\" : \"\"\n                            }`}\n                          />\n                        )}\n                      </div>\n                    </div>\n\n                    {/* 展开的详情内容 */}\n                    {isFileExpanded && hasDetail && detail?.content && (\n                      <div className=\"border-muted mt-1 mr-2 mb-1.5 ml-10\">\n                        <div className=\"text-muted-foreground border-foreground/20 border-l border-dashed pl-3 text-xs\">\n                          <div className=\"flex items-center justify-between gap-2 py-1\">\n                            <div className=\"flex items-center gap-2\">\n                              <Database className=\"size-3.5 text-blue-500 shrink-0\" />\n                              <span className=\"font-medium text-xs\">引用内容</span>\n                            </div>\n                            {detail?.filepath && (\n                              <button\n                                onClick={(e) => handleOpenFile(e, detail.filepath)}\n                                className=\"shrink-0 flex items-center gap-1 text-xs text-primary hover:underline\"\n                                title={t(\"record.chat.ragSources.openFile\", { defaultValue: \"Open file\" })}\n                              >\n                                <ExternalLink className=\"size-3\" />\n                                <span>打开文件</span>\n                              </button>\n                            )}\n                          </div>\n                          <p className=\"whitespace-pre-wrap wrap-break-word py-1 text-xs leading-relaxed\">\n                            {detail.content}\n                          </p>\n                        </div>\n                      </div>\n                    )}\n                  </li>\n                )\n              })}\n            </>\n          )}\n\n          {/* Agent 执行步骤 - 使用 AgentPlan embedded 模式 */}\n          {hasAgent && (\n            <AgentPlan\n              mode={mode}\n              isRunning={isRunning}\n              isThinking={isThinking}\n              currentThought={currentThought}\n              thoughtHistory={thoughtHistory}\n              completedSteps={completedSteps}\n              currentAction={currentAction}\n              currentObservation={currentObservation}\n              toolCalls={toolCalls}\n              pendingConfirmation={pendingConfirmation}\n              confirmationHistory={confirmationHistory}\n              currentStepStartTime={currentStepStartTime}\n              historyJson={agentHistoryJson}\n              onConfirm={onConfirm}\n              onCancel={onCancel}\n              embedded={true}\n            />\n          )}\n        </ul>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-clipboard.tsx",
    "content": "'use client'\nimport { useState, useEffect } from 'react';\nimport { BaseDirectory, copyFile, exists, mkdir, readFile } from '@tauri-apps/plugin-fs';\nimport useTagStore from \"@/stores/tag\";\nimport useSettingStore from \"@/stores/setting\";\nimport useMarkStore from \"@/stores/mark\";\nimport { v4 as uuid } from 'uuid'\nimport ocr from \"@/lib/ocr\";\nimport { fetchAiDesc, fetchAiDescByImage } from \"@/lib/ai/description\";\nimport { insertMark, Mark } from \"@/db/marks\";\nimport { CheckCircle, Highlighter, ImagePlus, LoaderCircle } from \"lucide-react\";\nimport { Chat } from \"@/db/chats\";\nimport { LocalImage } from '@/components/local-image';\nimport MessageControl from './message-control';\nimport useChatStore from '@/stores/chat';\nimport { Button } from '@/components/ui/button';\nimport { useTranslations } from 'next-intl';\nimport { uploadImage } from '@/lib/imageHosting';\n\nexport function ChatClipboard({chat}: { chat: Chat }) {\n  const [loading, setLoading] = useState(false)\n  const [type] = useState<'image' | 'text'>(chat.image ? 'image' : 'text')\n  const [countdown, setCountdown] = useState(5) // 5 seconds countdown\n  const [isCountingDown, setIsCountingDown] = useState(!chat.inserted) // Start countdown if not recorded\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { primaryModel, primaryImageMethod, enableImageRecognition } = useSettingStore()\n  const { fetchMarks, addQueue, setQueue, removeQueue } = useMarkStore()\n  const { updateInsert, deleteChat } = useChatStore()\n  const t = useTranslations('record.queue')\n  \n  useEffect(() => {\n    if (chat.inserted) {\n      setIsCountingDown(false);\n      return;\n    }\n    \n    const timer = setTimeout(() => {\n      if (!chat.inserted) {\n        deleteChat(chat.id);\n      }\n    }, 5000);\n    \n    return () => clearTimeout(timer);\n  }, [chat.id, chat.inserted, deleteChat]);\n  \n  useEffect(() => {\n    if (!isCountingDown) return;\n    \n    if (countdown <= 0) return;\n    \n    const interval = setInterval(() => {\n      setCountdown(prev => prev - 1);\n    }, 1000);\n    \n    return () => clearInterval(interval);\n  }, [countdown, isCountingDown]);\n  \n  useEffect(() => {\n    if (chat.inserted) {\n      setIsCountingDown(false);\n    }\n  }, [chat.inserted]);\n\n  async function handleInset() {\n    setLoading(true)\n    const queueId = uuid()\n    // 获取文件后缀\n    addQueue({ queueId, tagId: currentTagId!, progress: '保存图片', type: 'image', startTime: Date.now() })\n    const isImageFolderExists = await exists('image', { baseDir: BaseDirectory.AppData})\n    if (!isImageFolderExists) {\n      await mkdir('image', { baseDir: BaseDirectory.AppData})\n    }\n    if (!chat.image) return\n    const fromPath = chat.image.slice(1)\n    const toPath = `image/${queueId}.png`\n    await copyFile(fromPath, toPath, { fromPathBaseDir: BaseDirectory.AppData, toPathBaseDir: BaseDirectory.AppData})\n    let content = ''\n    let desc = ''\n    \n    // Skip image recognition if disabled\n    if (!enableImageRecognition) {\n      setQueue(queueId, { progress: t('save') });\n      content = ''\n      desc = ''\n    } else if (primaryImageMethod === 'vlm') {\n      // 使用 VLM 识别图片\n      setQueue(queueId, { progress: t('ai') });\n      const file = await readFile(toPath, { baseDir: BaseDirectory.AppData })\n      const base64 = `data:image/png;base64,${Buffer.from(file).toString('base64')}`\n      content = await fetchAiDescByImage(base64) || 'VLM Error'\n      desc = content\n    } else {\n      // 使用 OCR 识别图片\n      setQueue(queueId, { progress: t('ocr') });\n      content = await ocr(toPath)\n      setQueue(queueId, { progress: t('ai') });\n      if (primaryModel) {\n        desc = await fetchAiDesc(content).then(res => res ? res : content) || content\n      } else {\n        desc = content\n      }\n    }\n    const mark: Partial<Mark> = {\n      tagId: currentTagId,\n      type: 'image',\n      content,\n      url: `${queueId}.png`,\n      desc,\n    }\n    setQueue(queueId, { progress: t('upload') });\n    const fileData = await readFile(toPath, { baseDir: BaseDirectory.AppData  })\n    const blob = new Blob([new Uint8Array(fileData)], { type: 'image/png' })\n    const file = new File([blob], `${queueId}.png`, { type: 'image/png' })\n    // 上传图片\n    const url = await uploadImage(file)\n    if (url) {\n      mark.url = url\n    }\n    removeQueue(queueId)\n    await updateInsert(chat.id)\n    setLoading(false)\n    await insertMark(mark)\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  async function handleTextInset() {\n    await updateInsert(chat.id)\n    const mark: Partial<Mark> = {\n      tagId: currentTagId,\n      type: 'text',\n      content: chat.content,\n      desc: '',\n    }\n    insertMark(mark)\n    fetchMarks()\n    fetchTags()\n    getCurrentTag()\n  }\n\n  return (\n    type === 'image' && chat.image ? \n      <div className=\"flex-col leading-6\">\n        <p className=\"flex items-center\">\n          {t('detected')}\n          {isCountingDown && (\n            <span className=\"text-red-500 animate-pulse ml-2\">{countdown}s</span>\n          )}\n        </p>\n        <LocalImage src={chat.image} alt=\"\" width={0} height={0} className=\"max-h-96 max-w-96 w-auto mt-2 mb-3 border-8 rounded\" />\n        <MessageControl chat={chat}>\n          {\n            loading ? \n              <Button variant={\"ghost\"} size=\"sm\" disabled>\n                <LoaderCircle className=\"size-4 animate-spin\" />\n              </Button> : (\n              chat.inserted?\n                <Button variant={\"ghost\"} size=\"sm\" disabled>\n                  <CheckCircle className=\"size-4\" />\n                </Button> :\n                <div className=\"flex items-center gap-2\">\n                  <Button variant={\"ghost\"} size=\"sm\" onClick={handleInset}>\n                    <ImagePlus className=\"size-4\" />\n                  </Button>\n                </div>\n            )\n          }\n        </MessageControl>\n      </div> :\n      <div className=\"flex-col leading-6\">\n        <p className='flex items-center'>\n          {t('detected')}\n          {isCountingDown && (\n            <span className=\"text-red-500 animate-pulse ml-2\">{countdown}s</span>\n          )}\n        </p>\n        <p className='text-zinc-500'>{chat.content}</p>\n        <MessageControl chat={chat}>\n          {\n            chat.inserted ? \n              <Button variant={\"ghost\"} size=\"sm\" disabled>\n                <CheckCircle className=\"size-4\" />\n              </Button> :\n              <div className=\"flex items-center gap-2\">\n                <Button variant={\"ghost\"} size=\"sm\" onClick={handleTextInset}>\n                  <Highlighter className=\"size-4\" />\n                </Button>\n              </div>\n          }\n        </MessageControl>\n      </div>\n  )\n}"
  },
  {
    "path": "src/app/core/main/chat/chat-content.tsx",
    "content": "import React from 'react'\nimport useChatStore from '@/stores/chat'\nimport useTagStore from '@/stores/tag'\nimport { ArrowDownToLine, X, Loader2, QuoteIcon } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { Chat } from '@/db/chats'\nimport ChatPreview from './chat-preview'\nimport './chat.css'\nimport { NoteOutput } from './message-control/note-output'\nimport { MarkText } from './message-control/mark-text'\nimport { ChatClipboard } from './chat-clipboard'\nimport MessageControl from './message-control'\nimport ChatEmpty from './chat-empty'\nimport { useTranslations } from 'next-intl'\nimport ChatThinking from './chat-thinking'\nimport { Separator } from '@/components/ui/separator'\nimport { Button } from '@/components/ui/button'\nimport { McpToolCallCard } from './mcp-tool-call'\nimport { AgentExecutionStatus } from './agent-execution-status'\nimport { AgentPanelWithRag } from './agent-panel-with-rag'\nimport { ChatImages } from \"./chat-images\"\nimport { useIsMobile } from '@/hooks/use-mobile'\n\nconst BOTTOM_THRESHOLD = 24\nconst USER_SCROLL_GRACE_MS = 300\n\nconst ChatContent = React.memo(function ChatContent() {\n  const { chats, init, agentState, loading } = useChatStore()\n  const { currentTagId } = useTagStore()\n  const [isOnBottom, setIsOnBottom] = useState(true)\n  const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const contentRef = React.useRef<HTMLDivElement>(null)\n  const bottomAnchorRef = React.useRef<HTMLDivElement>(null)\n  const programmaticScrollRef = React.useRef(false)\n  const autoScrollEnabledRef = React.useRef(true)\n  const delayedScrollTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const lastUserScrollAtRef = React.useRef(0)\n\n  const isNearBottom = useCallback((element: Element) => {\n    return element.scrollHeight - element.scrollTop - element.clientHeight <= BOTTOM_THRESHOLD\n  }, [])\n\n  const handleScroll = useCallback(() => {\n    const md = wrapperRef.current\n    if (!md) return\n\n    const onBottom = isNearBottom(md)\n    setIsOnBottom(onBottom)\n\n    if (programmaticScrollRef.current) {\n      if (onBottom) {\n        programmaticScrollRef.current = false\n      }\n      return\n    }\n\n    const isLikelyUserScroll = Date.now() - lastUserScrollAtRef.current < USER_SCROLL_GRACE_MS\n\n    // 只有用户主动离开底部时才关闭自动滚动\n    if (onBottom) {\n      setAutoScrollEnabled(true)\n    } else if (isLikelyUserScroll) {\n      setAutoScrollEnabled(false)\n    }\n  }, [isNearBottom])\n\n  const markUserScrollIntent = useCallback(() => {\n    lastUserScrollAtRef.current = Date.now()\n  }, [])\n\n  const performAutoScroll = useCallback(() => {\n    programmaticScrollRef.current = true\n    bottomAnchorRef.current?.scrollIntoView({ block: 'end' })\n    setIsOnBottom(true)\n\n    requestAnimationFrame(() => {\n      setTimeout(() => {\n        programmaticScrollRef.current = false\n        const md = wrapperRef.current\n        if (md) {\n          setIsOnBottom(isNearBottom(md))\n        }\n      }, 0)\n    })\n\n    if (delayedScrollTimeoutRef.current) {\n      clearTimeout(delayedScrollTimeoutRef.current)\n    }\n\n    delayedScrollTimeoutRef.current = setTimeout(() => {\n      if (!autoScrollEnabledRef.current) return\n\n      programmaticScrollRef.current = true\n      bottomAnchorRef.current?.scrollIntoView({ block: 'end' })\n      setIsOnBottom(true)\n\n      requestAnimationFrame(() => {\n        setTimeout(() => {\n          programmaticScrollRef.current = false\n          const md = wrapperRef.current\n          if (md) {\n            setIsOnBottom(isNearBottom(md))\n          }\n        }, 0)\n      })\n    }, 500)\n  }, [isNearBottom])\n\n  // 手动滚动到底部并启用自动滚动\n  const handleScrollToBottom = useCallback(() => {\n    performAutoScroll()\n    setAutoScrollEnabled(true)\n  }, [performAutoScroll])\n\n  useEffect(() => {\n    const md = wrapperRef.current\n    if (!md) return\n\n    const handleTouchStart = () => markUserScrollIntent()\n    const handleTouchMove = () => markUserScrollIntent()\n    const handleWheel = () => markUserScrollIntent()\n    const handlePointerDown = () => markUserScrollIntent()\n\n    md.addEventListener('scroll', handleScroll)\n    md.addEventListener('touchstart', handleTouchStart, { passive: true })\n    md.addEventListener('touchmove', handleTouchMove, { passive: true })\n    md.addEventListener('wheel', handleWheel, { passive: true })\n    md.addEventListener('pointerdown', handlePointerDown, { passive: true })\n\n    return () => {\n      md.removeEventListener('scroll', handleScroll)\n      md.removeEventListener('touchstart', handleTouchStart)\n      md.removeEventListener('touchmove', handleTouchMove)\n      md.removeEventListener('wheel', handleWheel)\n      md.removeEventListener('pointerdown', handlePointerDown)\n    }\n  }, [handleScroll, markUserScrollIntent])\n\n  useEffect(() => {\n    init(currentTagId)\n  }, [currentTagId, init])\n\n  useEffect(() => {\n    autoScrollEnabledRef.current = autoScrollEnabled\n  }, [autoScrollEnabled])\n\n  useEffect(() => {\n    const md = wrapperRef.current\n    const content = contentRef.current\n    if (!md || !content) return\n\n    const syncScrollState = () => {\n      const onBottom = isNearBottom(md)\n\n      if (autoScrollEnabled) {\n        performAutoScroll()\n        return\n      }\n\n      setIsOnBottom(onBottom)\n    }\n\n    const observer = new ResizeObserver(syncScrollState)\n    const mutationObserver = new MutationObserver(() => {\n      syncScrollState()\n    })\n\n    observer.observe(content)\n    mutationObserver.observe(content, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    })\n\n    return () => {\n      observer.disconnect()\n      mutationObserver.disconnect()\n    }\n  }, [autoScrollEnabled, isNearBottom, performAutoScroll])\n\n  // 监听消息变化，仅在启用自动滚动时才滚动\n  useEffect(() => {\n    if (autoScrollEnabled) {\n      performAutoScroll()\n    }\n  }, [chats, autoScrollEnabled, performAutoScroll])\n\n  // Agent 执行时，仅在启用自动滚动时才滚动到底部\n  useEffect(() => {\n    if (autoScrollEnabled && agentState.isRunning) {\n      performAutoScroll()\n    }\n  }, [agentState.currentThought, agentState.thoughtHistory, agentState.pendingConfirmation, agentState.isRunning, autoScrollEnabled, performAutoScroll])\n\n  // Loading 状态变化时，仅在启用自动滚动时才滚动到底部\n  useEffect(() => {\n    if (autoScrollEnabled && loading) {\n      performAutoScroll()\n    }\n  }, [loading, autoScrollEnabled, performAutoScroll])\n\n  useEffect(() => {\n    return () => {\n      if (delayedScrollTimeoutRef.current) {\n        clearTimeout(delayedScrollTimeoutRef.current)\n      }\n    }\n  }, [])\n\n  // 判断是否应该显示 loading：loading=true 且最后一个 AI 消息还没有内容\n  const shouldShowLoading = useMemo(() => {\n    if (!loading) return false\n    if (agentState.isRunning) return false\n\n    const lastChat = chats[chats.length - 1]\n    // 如果最后一个消息是 system 角色且有内容或思考内容，说明 AI 已经开始输出了\n    if (lastChat?.role === 'system' && (lastChat.content || lastChat.thinking)) {\n      return false\n    }\n\n    return true\n  }, [loading, agentState.isRunning, chats])\n\n  return <div ref={wrapperRef} id=\"chats-wrapper\" className=\"flex-1 relative overflow-y-auto overflow-x-hidden w-full flex flex-col items-end p-4 gap-6 [overflow-anchor:none]\">\n    <div ref={contentRef} className=\"w-full flex flex-col items-end gap-6\">\n      {\n        chats.length ? chats.map((chat) => {\n          return <Message key={chat.id} chat={chat} />\n        }) : <ChatEmpty />\n      }\n\n      {/* Loading 指示器 - 服务器等待时显示 */}\n      {shouldShowLoading && (\n        <div className=\"flex w-full min-w-0 -mt-6\">\n          <div className='text-sm leading-6 flex-1 flex items-center gap-2 text-muted-foreground'>\n            <Loader2 className=\"size-4 animate-spin\" />\n            <span>正在思考...</span>\n          </div>\n        </div>\n      )}\n\n      <div ref={bottomAnchorRef} className=\"h-px w-full\" />\n    </div>\n\n    {\n      !isOnBottom && <Button variant=\"outline\" className='sticky bottom-0 size-8 right-0' onClick={handleScrollToBottom}>\n        <ArrowDownToLine className='size-4' />\n      </Button>\n    }\n  </div>\n})\nChatContent.displayName = 'ChatContent'\n\nconst MessageWrapper = React.memo(function MessageWrapper({ chat, children }: { chat: Chat, children: React.ReactNode }) {\n  const { deleteChat } = useChatStore()\n  const [showDelete, setShowDelete] = useState(false)\n  const isMobile = useIsMobile()\n\n  const handleDelete = useCallback(() => {\n    deleteChat(chat.id)\n  }, [chat.id, deleteChat])\n  const shouldShowDelete = showDelete\n\n  // 用户消息：右对齐，带边框和背景\n  if (chat.role === 'user') {\n    return (\n      <div className=\"flex w-full justify-end\">\n        <div\n          className=\"group relative max-w-[85%] rounded-lg border px-3 py-2\"\n          onMouseEnter={() => {\n            if (!isMobile) setShowDelete(true)\n          }}\n          onMouseLeave={() => {\n            if (!isMobile) setShowDelete(false)\n          }}\n          onClick={() => {\n            if (isMobile) setShowDelete((prev) => !prev)\n          }}\n        >\n          <div className='text-sm leading-6 wrap-break-word text-primary-foreground'>\n            {children}\n          </div>\n          {shouldShowDelete && (\n            <Button\n              onClick={(event) => {\n                event.stopPropagation()\n                handleDelete()\n              }}\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"absolute -top-2 -right-2 h-6 w-6 rounded-full bg-background border shadow-sm\"\n            >\n              <X className=\"h-3 w-3\" />\n            </Button>\n          )}\n        </div>\n      </div>\n    )\n  }\n\n  // AI 消息：左对齐，无边框，无图标\n  return (\n    <div className=\"flex w-full min-w-0\">\n      <div className='text-sm leading-6 flex-1 word-break min-w-0 overflow-hidden'>\n        {children}\n      </div>\n    </div>\n  )\n})\nMessageWrapper.displayName = 'MessageWrapper'\n\nconst Message = React.memo(function Message({ chat }: { chat: Chat }) {\n  const t = useTranslations()\n  const { deleteChat, getMcpToolCallsByChatId, loading, agentState } = useChatStore()\n  const content = chat.content\n  const isActiveAgentMessage = chat.role === 'system' && agentState.activeChatId === chat.id\n  const isLiveAgentVisible = isActiveAgentMessage && (agentState.isRunning || agentState.isFinalAnswerMode)\n\n  const handleRemoveClearContext = useCallback(() => {\n    deleteChat(chat.id)\n  }, [chat.id, deleteChat])\n\n  // 解析 RAG 来源\n  const ragSources = useMemo(() => {\n    if (!chat.ragSources) return []\n    try {\n      return JSON.parse(chat.ragSources) as string[]\n    } catch {\n      return []\n    }\n  }, [chat.ragSources])\n\n  // 解析 RAG 来源详情\n  const ragSourceDetails = useMemo(() => {\n    if (!chat.ragSourceDetails) return []\n    try {\n      return JSON.parse(chat.ragSourceDetails) as Array<{\n        filepath: string\n        filename: string\n        content: string\n      }>\n    } catch {\n      return []\n    }\n  }, [chat.ragSourceDetails])\n\n  // 获取该消息关联的 MCP 工具调用\n  const mcpToolCalls = useMemo(() => getMcpToolCallsByChatId(chat.id), [chat.id, getMcpToolCallsByChatId])\n\n  // 解析图片数组\n  const images = useMemo(() => {\n    if (!chat.images) return []\n    try {\n      return JSON.parse(chat.images) as string[]\n    } catch {\n      return []\n    }\n  }, [chat.images])\n\n  // 解析引用数据\n  const quoteData = useMemo(() => {\n    if (!chat.quoteData) return null\n    try {\n      return JSON.parse(chat.quoteData) as {\n        quote: string\n        fullContent: string\n        fileName: string\n        startLine: number\n        endLine: number\n        from: number\n        to: number\n        articlePath: string\n      }\n    } catch {\n      return null\n    }\n  }, [chat.quoteData])\n\n  switch (chat.type) {\n    case 'clear':\n      return <div className=\"w-full flex justify-center items-center gap-4 px-10\">\n        <Separator className='flex-1' />\n        <div className=\"flex justify-center items-center gap-2 w-32 group h-8\">\n          <p className=\"text-sm text-center text-muted-foreground\">{t('record.chat.input.clearContext.tooltip')}</p>\n          <X className=\"size-4 hidden group-hover:flex cursor-pointer\" onClick={handleRemoveClearContext} />\n        </div>\n        <Separator className='flex-1' />\n      </div>\n\n    case 'clipboard':\n      return <MessageWrapper chat={chat}>\n        <ChatClipboard chat={chat} />\n      </MessageWrapper>\n\n    case 'note':\n      return <MessageWrapper chat={chat}>\n        {\n          <div className='w-full overflow-x-hidden'>\n            <div className='flex justify-between'>\n              <p>{t('record.chat.content.organize')}</p>\n            </div>\n            <ChatThinking chat={chat} />\n            {\n              <div className={`${content ? 'note-wrapper border w-full overflow-y-auto overflow-x-hidden my-2 p-4 rounded-lg' : ''}`}>\n                <ChatPreview text={content || ''} streaming={loading && chat.role === 'system'} />\n              </div>\n            }\n            <MessageControl chat={chat}>\n              <NoteOutput chat={chat} />\n            </MessageControl>\n          </div>\n        }\n      </MessageWrapper>\n\n    default:\n      // 检查 AI 消息是否有实际内容（没有内容时不渲染）\n      const hasContent = chat.role === 'system' && (\n        !!content ||\n        !!chat.thinking ||\n        (chat.agentHistory && chat.agentHistory.length > 0) ||\n        ragSources.length > 0 ||\n        ragSourceDetails.length > 0 ||\n        mcpToolCalls.length > 0 ||\n        isLiveAgentVisible\n      )\n\n      // 用户消息或有内容的 AI 消息才渲染\n      if (chat.role === 'system' && !hasContent) {\n        return null\n      }\n\n      return <MessageWrapper chat={chat}>\n        {chat.role === 'system' ? (\n          // AI 消息：所有内容放在一个容器中\n          <div className=\"w-full space-y-4\">\n            {/* 合并的 RAG 和 Agent 面板 - 只在有 agentHistory 时显示（历史模式） */}\n            {/* 实时执行时，RAG 和 Agent 步骤在 AgentExecutionStatusWrapper 中统一显示 */}\n            {chat.agentHistory && (\n              <AgentPanelWithRag\n                ragSources={ragSources}\n                ragSourceDetails={ragSourceDetails}\n                agentHistoryJson={chat.agentHistory}\n              />\n            )}\n\n            {isLiveAgentVisible && (\n              <div className=\"space-y-2\">\n                {!agentState.isFinalAnswerMode && (agentState.isRunning || agentState.completedSteps?.length > 0 || agentState.thoughtHistory?.length > 0) && (\n                  <AgentExecutionStatus />\n                )}\n                {agentState.isFinalAnswerMode && agentState.finalAnswerContent && (\n                  <ChatPreview\n                    text={agentState.finalAnswerContent}\n                    streaming={loading && isActiveAgentMessage}\n                  />\n                )}\n              </div>\n            )}\n\n            {/* MCP 工具调用展示 */}\n            {mcpToolCalls.length > 0 && (\n              <div className=\"space-y-4\">\n                {mcpToolCalls.map(toolCall => (\n                  <McpToolCallCard key={toolCall.id} toolCall={toolCall} />\n                ))}\n              </div>\n            )}\n\n            <ChatThinking chat={chat} />\n            <ChatPreview text={content || ''} streaming={loading && isActiveAgentMessage} />\n            <MessageControl chat={chat}>\n              <MarkText chat={chat} />\n            </MessageControl>\n          </div>\n        ) : (\n          // 用户消息\n          <div className=\"w-full space-y-3 text-primary\">\n            {/* 显示用户消息中的图片 */}\n            {images.length > 0 && <ChatImages images={images} />}\n            {/* 显示用户消息中的引用 */}\n            {quoteData && (\n              <div className=\"flex flex-col gap-1 text-[11px]\">\n                <div className=\"flex items-center gap-1\">\n                  <QuoteIcon className=\"size-3 text-primary/75\" />\n                  <span className=\"text-primary/75\">\n                    {quoteData.startLine !== -1 && quoteData.endLine !== -1 ? (\n                      quoteData.startLine === quoteData.endLine ? (\n                        t('record.chat.quote.lineSingle', { fileName: quoteData.fileName, line: quoteData.startLine })\n                      ) : (\n                        t('record.chat.quote.lineRange', { fileName: quoteData.fileName, startLine: quoteData.startLine, endLine: quoteData.endLine })\n                      )\n                    ) : (\n                      t('record.chat.quote.noLine', { fileName: quoteData.fileName })\n                    )}\n                  </span>\n                </div>\n                <div className=\"text-primary/50 line-clamp-2 whitespace-pre-wrap pl-4\">\n                  {quoteData.fullContent}\n                </div>\n              </div>\n            )}\n            {content && (\n              <div className=\"whitespace-pre-wrap\">{content}</div>\n            )}\n          </div>\n        )}\n      </MessageWrapper>\n  }\n})\nMessage.displayName = 'Message'\n\nexport default ChatContent\n"
  },
  {
    "path": "src/app/core/main/chat/chat-empty.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport useChatStore from '@/stores/chat'\nimport { useMemo, useState, useEffect } from 'react'\nimport { Trash2, FileEdit, FileText, Lightbulb, ArrowRight, MessageCircle } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport emitter from '@/lib/emitter'\nimport dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport 'dayjs/locale/zh-cn'\nimport 'dayjs/locale/en'\nimport useSettingStore from '@/stores/setting'\nimport { QuickPrompt } from '@/lib/ai/placeholder'\n\n// 初始化 dayjs 插件\ndayjs.extend(relativeTime)\n\n// 格式化相对时间\nfunction formatRelativeTime(timestamp: number, locale: string): string {\n  const dayjsLocale = locale === 'en' ? 'en' : 'zh-cn'\n  return dayjs(timestamp).locale(dayjsLocale).fromNow()\n}\n\nexport default function ChatEmpty() {\n  const t = useTranslations('record.chat.empty')\n  const { language } = useSettingStore()\n\n  const {\n    conversations,\n    currentConversationId,\n    switchConversation,\n    deleteConversation\n  } = useChatStore()\n\n  const [aiPrompts, setAiPrompts] = useState<QuickPrompt[]>([])\n\n  // 快速 prompt 模板 - 默认模板\n  const defaultQuickPrompts = useMemo(() => [\n    { id: 1, icon: <FileEdit className=\"w-4 h-4\" />, text: t('quickPrompts.writeNote') || '帮我写一篇笔记' },\n    { id: 2, icon: <FileText className=\"w-4 h-4\" />, text: t('quickPrompts.summarize') || '帮我总结这段内容' },\n    { id: 3, icon: <Lightbulb className=\"w-4 h-4\" />, text: t('quickPrompts.brainstorm') || '帮我头脑风暴一些想法' },\n  ], [t])\n\n  // 监听来自 chat-input 的 AI 提示词生成事件\n  useEffect(() => {\n    const handleAiPromptsGenerated = (prompts: QuickPrompt[]) => {\n      if (prompts.length >= 3) {\n        setAiPrompts(prompts)\n      }\n    }\n\n    emitter.on('ai-prompts-generated', handleAiPromptsGenerated)\n    return () => {\n      emitter.off('ai-prompts-generated', handleAiPromptsGenerated)\n    }\n  }, [])\n\n  // 使用 AI 生成的提示词或默认提示词\n  const quickPrompts = useMemo(() => {\n    // 如果 AI 成功生成了至少3条提示词，使用 AI 生成的\n    if (aiPrompts.length >= 3) {\n      return aiPrompts.slice(0, 3).map((prompt, index) => ({\n        id: `ai-${index}`,\n        icon: <Lightbulb className=\"w-4 h-4\" />,\n        text: prompt.text\n      }))\n    }\n    // 否则使用默认提示词\n    return defaultQuickPrompts\n  }, [aiPrompts, defaultQuickPrompts])\n\n  const handleQuickPrompt = (prompt: string) => {\n    // 将文本插入到输入框\n    emitter.emit('quick-prompt-insert', prompt)\n  }\n\n  // 获取最近 3 条会话（排除当前会话和空会话）\n  const recentConversations = useMemo(() => {\n    return conversations\n      .filter(c => c.id !== currentConversationId && c.messageCount > 0)\n      .sort((a, b) => b.updatedAt - a.updatedAt)\n      .slice(0, 3)\n  }, [conversations, currentConversationId])\n\n  const handleSwitchConversation = async (id: number) => {\n    await switchConversation(id)\n  }\n\n  const handleDelete = async (id: number) => {\n    await deleteConversation(id)\n  }\n\n  return (\n    <div className=\"absolute top-0 right-0 w-full flex flex-col items-center justify-center h-full overflow-hidden\">\n      {/* Dashed background pattern - only visible when empty */}\n      <div\n        className=\"absolute inset-0 opacity-[0.03] dark:opacity-[0.05] pointer-events-none\"\n        style={{\n          backgroundImage: `\n            linear-gradient(to right, currentColor 1px, transparent 1px),\n            linear-gradient(to bottom, currentColor 1px, transparent 1px)\n          `,\n          backgroundSize: '40px 40px',\n          backgroundPosition: 'center center'\n        }}\n      />\n\n      {/* Gradient fade overlay on edges */}\n      <div\n        className=\"absolute inset-0 pointer-events-none\"\n        style={{\n          background: `\n            linear-gradient(to right, var(--background) 0%, transparent 15%, transparent 85%, var(--background) 100%),\n            linear-gradient(to bottom, var(--background) 0%, transparent 15%, transparent 85%, var(--background) 100%)\n          `\n        }}\n      />\n\n      <div className=\"relative max-w-[340px] w-full px-2 space-y-6\">\n        {/* Header */}\n        <div className=\"text-center space-y-3\">\n          <div className=\"flex items-center justify-center gap-2\">\n            <MessageCircle className=\"w-5 h-5 text-primary\" />\n            <h2 className=\"text-xl font-semibold tracking-tight\">\n              {t('title')}\n            </h2>\n          </div>\n          <p className=\"text-muted-foreground text-sm\">\n            {t('subtitle')}\n          </p>\n        </div>\n\n        {/* Quick Prompts */}\n        <div className=\"space-y-2\">\n          <p className=\"text-xs text-muted-foreground px-1\">{t('quickPrompts.title') || '快速开始'}</p>\n          {quickPrompts.map((prompt) => (\n            <div\n              key={prompt.id}\n              onClick={() => handleQuickPrompt(prompt.text)}\n              className=\"w-full bg-primary-foreground px-4 h-10 rounded-lg border hover:border-primary/50 transition-colors text-left group cursor-pointer flex items-center\"\n            >\n              <div className=\"flex items-center justify-between w-full\">\n                <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                  <span className=\"text-muted-foreground\">{prompt.icon}</span>\n                  <span className=\"text-sm font-medium truncate group-hover:text-primary transition-colors\">\n                    {prompt.text}\n                  </span>\n                </div>\n                <ArrowRight className=\"w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity\" />\n              </div>\n            </div>\n          ))}\n        </div>\n\n        {/* Recent Conversations */}\n        {recentConversations.length > 0 && (\n          <div className=\"space-y-2\">\n            <p className=\"text-xs text-muted-foreground px-1\">{t('recentConversations')}</p>\n            {recentConversations.map(conv => (\n              <div\n                key={conv.id}\n                onClick={() => handleSwitchConversation(conv.id)}\n                className=\"w-full px-1 h-5 rounded-lg transition-colors text-left group cursor-pointer flex items-center\"\n              >\n                <div className=\"flex items-center justify-between w-full\">\n                  <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                    <span className=\"text-xs font-medium truncate group-hover:text-primary transition-colors pr-14\">\n                      {conv.title}\n                    </span>\n                  </div>\n                  <div className=\"shrink-0 ml-auto flex items-center justify-end relative\">\n                    {/* 时间戳 - 悬停时隐藏 */}\n                    <span className=\"absolute right-0 text-xs text-muted-foreground opacity-100 group-hover:opacity-0 transition-opacity duration-200 ease-out whitespace-nowrap\">\n                      {formatRelativeTime(conv.updatedAt, language)}\n                    </span>\n                    {/* 删除按钮 - 悬停时显示 */}\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      onClick={(e) => {\n                        e.stopPropagation()\n                        handleDelete(conv.id)\n                      }}\n                      className=\"opacity-0 group-hover:opacity-100 z-50 transition-all duration-200 ease-out hover:text-destructive h-6 w-6\"\n                      title={t('deleteConversation')}\n                    >\n                      <Trash2 className=\"w-3 h-3 transition-transform duration-150 group-hover/button:scale-110\" />\n                    </Button>\n                  </div>\n                </div>\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-footer.tsx",
    "content": "\"use client\"\n\nimport { BotMessageSquare, BotOff, Drama } from \"lucide-react\"\nimport usePromptStore from \"@/stores/prompt\"\nimport useSettingStore from \"@/stores/setting\"\nimport { useTranslations } from \"next-intl\"\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\n\nexport function ChatFooter() {\n  const t = useTranslations('record.chat.header')\n  const { currentPrompt } = usePromptStore()\n  const { primaryModel, aiModelList } = useSettingStore()\n\n  // 查找当前选中的模型\n  const findSelectedModel = () => {\n    if (!primaryModel || !aiModelList) return null\n    \n    for (const config of aiModelList) {\n      // 检查新的 models 数组结构\n      if (config.models && config.models.length > 0) {\n        const targetModel = config.models.find(model => model.id === primaryModel)\n        if (targetModel) {\n          return {\n            model: targetModel.model,\n            configTitle: config.title\n          }\n        }\n      } else {\n        // 向后兼容：处理旧的单模型结构\n        if (config.key === primaryModel) {\n          return {\n            model: config.model,\n            configTitle: config.title\n          }\n        }\n      }\n    }\n    return null\n  }\n\n  const selectedModel = findSelectedModel()\n\n  return (\n    <TooltipProvider>\n      <footer className=\"flex h-6 w-full items-center justify-between border-t border-border bg-background px-2 text-xs text-muted-foreground\">\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div className=\"flex min-w-0 items-center gap-1\">\n              <Drama className=\"size-3\" />\n              <span className=\"truncate\">{currentPrompt?.title}</span>\n            </div>\n          </TooltipTrigger>\n          <TooltipContent side=\"top\">\n            <p>{currentPrompt?.title || '-'}</p>\n          </TooltipContent>\n        </Tooltip>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <div className=\"flex min-w-0 items-center gap-1\">\n              {selectedModel ? (\n                <>\n                  <BotMessageSquare className=\"size-3\" />\n                  <span className=\"truncate\">\n                    {selectedModel.model}\n                    <span className=\"ml-1\">({selectedModel.configTitle})</span>\n                  </span>\n                </>\n              ) : (\n                <>\n                  <BotOff className=\"size-3\" />\n                  <span>{t('noModel')}</span>\n                </>\n              )}\n            </div>\n          </TooltipTrigger>\n          <TooltipContent side=\"top\">\n            <p>{selectedModel ? `${selectedModel.model} (${selectedModel.configTitle})` : t('noModel')}</p>\n          </TooltipContent>\n        </Tooltip>\n      </footer>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-header.tsx",
    "content": "\"use client\"\n\nimport { useState, useMemo } from 'react'\nimport { MessageSquarePlus, ChevronDown, Search, Trash2 } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport useChatStore from \"@/stores/chat\"\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { useTranslations } from 'next-intl'\nimport dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport 'dayjs/locale/zh-cn'\nimport 'dayjs/locale/en'\nimport useSettingStore from '@/stores/setting'\n\ndayjs.extend(relativeTime)\n\nfunction formatRelativeTime(timestamp: number, locale: string): string {\n  const dayjsLocale = locale === 'en' ? 'en' : 'zh-cn'\n  return dayjs(timestamp).locale(dayjsLocale).fromNow()\n}\n\nexport function ChatHeader() {\n  const { startNewConversation, conversations, currentConversationId, switchConversation, deleteConversation, loading } = useChatStore()\n  const { language } = useSettingStore()\n  const t = useTranslations()\n  const tEmpty = useTranslations('record.chat.empty')\n\n  const [showHistoryDropdown, setShowHistoryDropdown] = useState(false)\n  const [searchQuery, setSearchQuery] = useState('')\n\n  // 没有消息或正在加载时禁用新对话按钮\n  const hasCurrentMessages = conversations.some(c => c.id === currentConversationId && c.messageCount > 0)\n  const isDisabled = !hasCurrentMessages || loading\n\n  // 过滤并排序会话（排除空会话）\n  const filteredConversations = useMemo(() => {\n    return conversations\n      .filter(c => c.messageCount > 0)\n      .filter(c => c.title.toLowerCase().includes(searchQuery.toLowerCase()))\n      .sort((a, b) => {\n        if (a.isPinned && !b.isPinned) return -1\n        if (!a.isPinned && b.isPinned) return 1\n        return b.updatedAt - a.updatedAt\n      })\n  }, [conversations, searchQuery])\n\n  // 当前会话标题（如果有消息的话）\n  const currentConversation = conversations.find(c => c.id === currentConversationId)\n  const dropdownTitle = currentConversation && currentConversation.messageCount > 0\n    ? currentConversation.title\n    : tEmpty('conversationHistory')\n\n  return (\n    <header className=\"h-12 w-full flex items-center justify-between border-b px-4 gap-2\">\n      {/* 左侧：历史对话下拉 */}\n      <div className=\"flex items-center gap-2\">\n        <DropdownMenu open={showHistoryDropdown} onOpenChange={setShowHistoryDropdown}>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              className=\"px-2 hover:bg-transparent cursor-pointer justify-start gap-1.5\"\n            >\n              <span className=\"text-sm font-medium truncate max-w-30\">{dropdownTitle}</span>\n              <span className=\"text-xs text-muted-foreground\">\n                ({filteredConversations.length})\n              </span>\n              <ChevronDown className=\"w-4 h-4 text-muted-foreground\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent\n            align=\"start\"\n            className=\"w-75 max-h-100 overflow-y-auto\"\n          >\n            <div className=\"px-2 py-2\">\n              <div className=\"relative\">\n                <Search className=\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground\" />\n                <Input\n                  placeholder={tEmpty('searchPlaceholder')}\n                  value={searchQuery}\n                  onChange={(e) => setSearchQuery(e.target.value)}\n                  className=\"pl-8 h-8 text-sm\"\n                />\n              </div>\n            </div>\n            <DropdownMenuSeparator />\n            {filteredConversations.length === 0 ? (\n              <div className=\"px-4 py-8 text-center text-sm text-muted-foreground\">\n                {searchQuery ? tEmpty('noMatchingConversations') : tEmpty('noConversationHistory')}\n              </div>\n            ) : (\n              <div className=\"max-h-75 overflow-y-auto\">\n                {filteredConversations.map(conv => (\n                  <DropdownMenuItem\n                    key={conv.id}\n                    className=\"cursor-pointer group\"\n                  >\n                    <div\n                      className=\"flex-1 min-w-0\"\n                      onClick={() => switchConversation(conv.id)}\n                    >\n                      <div className=\"flex items-center justify-between\">\n                        <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                          <span className=\"text-sm truncate group-hover:text-primary transition-colors\">\n                            {conv.title}\n                          </span>\n                        </div>\n                        <div className=\"shrink-0 ml-auto flex items-center gap-2\">\n                          <span className=\"text-xs text-muted-foreground\">\n                            {formatRelativeTime(conv.updatedAt, language)}\n                          </span>\n                          <button\n                            onClick={(e) => {\n                              e.stopPropagation()\n                              deleteConversation(conv.id)\n                            }}\n                            className=\"flex items-center justify-center rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 transition-all duration-200 ease-out hover:text-destructive hover:bg-destructive/10 active:scale-95\"\n                            title={tEmpty('deleteConversation')}\n                          >\n                            <Trash2 className=\"w-3.5 h-3.5 transition-transform duration-150 group-hover/button:scale-110\" />\n                          </button>\n                        </div>\n                      </div>\n                    </div>\n                  </DropdownMenuItem>\n                ))}\n              </div>\n            )}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n\n      {/* 右侧：新建对话 */}\n      <TooltipButton\n        icon={<MessageSquarePlus />}\n        tooltipText={t('record.chat.input.newChat')}\n        side=\"bottom\"\n        onClick={() => startNewConversation()}\n        disabled={isDisabled}\n      />\n    </header>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-images.tsx",
    "content": "\"use client\"\nimport Image from \"next/image\"\nimport { useState } from \"react\"\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\"\n\ninterface ChatImagesProps {\n  images: string[]\n}\n\nexport function ChatImages({ images }: ChatImagesProps) {\n  const [selectedImage, setSelectedImage] = useState<string | null>(null)\n\n  if (!images || images.length === 0) return null\n\n  return (\n    <>\n      <div className=\"flex flex-wrap gap-2 my-2\">\n        {images.map((imageUrl, index) => (\n          <div\n            key={index}\n            className=\"relative cursor-pointer rounded-lg overflow-hidden border hover:border-primary transition-colors\"\n            style={{ width: '120px', height: '120px' }}\n            onClick={() => setSelectedImage(imageUrl)}\n          >\n            <Image\n              src={imageUrl}\n              alt={`Image ${index + 1}`}\n              fill\n              className=\"object-cover\"\n              unoptimized\n            />\n          </div>\n        ))}\n      </div>\n\n      {selectedImage && (\n        <Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>\n          <DialogContent className=\"max-w-4xl max-h-[90vh] p-0\">\n            <div className=\"relative w-full h-full flex items-center justify-center p-4\">\n              <Image\n                src={selectedImage}\n                alt=\"Full size image\"\n                width={1200}\n                height={800}\n                className=\"object-contain max-h-[85vh]\"\n                unoptimized\n              />\n            </div>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-input.tsx",
    "content": "\"use client\"\nimport * as React from \"react\"\nimport { useEffect, useMemo, useRef, useState, useCallback } from \"react\"\nimport useSettingStore from \"@/stores/setting\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport useChatStore from \"@/stores/chat\"\nimport useMarkStore from \"@/stores/mark\"\nimport useArticleStore from \"@/stores/article\"\nimport { fetchAiQuickPrompts } from \"@/lib/ai/placeholder\"\nimport { useTranslations } from 'next-intl'\nimport { useLocalStorage } from 'react-use';\nimport { ModelSelect } from \"./model-select\"\nimport { getWorkspacePath } from \"@/lib/workspace\"\nimport { PromptSelect } from \"./prompt-select\"\nimport { ChatSend } from \"./chat-send\"\nimport { LinkedFileDisplay } from \"./file-link\"\nimport { LinkedResource, MarkdownFile, LinkedFolder } from \"@/lib/files\"\nimport { McpButton } from \"./mcp-button\"\nimport { RagSwitch } from \"./rag-switch\"\nimport { ClipboardMonitor } from \"./clipboard-monitor\"\nimport emitter from \"@/lib/emitter\"\nimport { ChatToolsDrawer } from \"@/app/mobile/chat/components/chat-tools-drawer\"\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { ImageAttachments, ImageAttachment } from \"./image-attachments\"\nimport { ImageIcon } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { isMobileDevice } from '@/lib/check'\nimport { QuoteDisplay } from \"./quote-display\"\nimport type { PendingQuote } from \"@/stores/chat\"\nimport { convertFileSrc } from \"@tauri-apps/api/core\"\nimport { readTextFile, writeFile, BaseDirectory, exists } from \"@tauri-apps/plugin-fs\"\nimport { ShineBorder } from \"@/components/ui/shine-border\"\nimport {\n  DndContext,\n  closestCenter,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  horizontalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\nimport { buildTypingFrames } from './onboarding-typing'\n\n// 可排序的工具栏项组件 - 定义在外部以避免每次 ChatInput re-render 时重新创建\ninterface SortableToolbarItemProps {\n  id: string\n}\n\nconst SortableToolbarItem = React.memo(function SortableToolbarItem({ id }: SortableToolbarItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  // 渲染对应的工具栏组件\n  const renderToolbarItem = () => {\n    switch (id) {\n      case 'modelSelect':\n        return <ModelSelect />\n      case 'promptSelect':\n        return <PromptSelect />\n      case 'mcpButton':\n        return <McpButton />\n      case 'ragSwitch':\n        return <RagSwitch />\n      case 'clipboardMonitor':\n        return <ClipboardMonitor />\n      default:\n        return null\n    }\n  }\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      className=\"cursor-grab active:cursor-grabbing\"\n    >\n      {renderToolbarItem()}\n    </div>\n  )\n})\nSortableToolbarItem.displayName = 'SortableToolbarItem'\n\n\nexport const ChatInput = React.memo(function ChatInput() {\n  const [text, setText] = useState(\"\")\n  const { primaryModel, chatToolbarConfigPc, setChatToolbarConfigPc } = useSettingStore()\n  const {\n    chats,\n    loading,\n    setLinkedResource: setChatLinkedResource,\n    setLinkedResourcePreview,\n    onboardingPromptDraft,\n    setOnboardingPromptDraft,\n    pendingQuote,\n    setPendingQuote,\n    clearPendingQuote,\n  } = useChatStore()\n  const { marks, trashState } = useMarkStore()\n  const { activeFilePath } = useArticleStore()\n  const [isComposing, setIsComposing] = useState(false)\n  const [placeholder, setPlaceholder] = useState('')\n  const t = useTranslations()\n  const [inputHistory, setInputHistory] = useLocalStorage<string[]>('chat-input-history', [])\n  const [historyIndex, setHistoryIndex] = useState(-1)\n  const [tempInput, setTempInput] = useState('')\n  const [linkedResource, setLinkedResource] = useState<LinkedResource | null>(null)\n  const [attachedImages, setAttachedImages] = useState<ImageAttachment[]>([])\n  const chatSendRef = useRef<any>(null)\n  const isMobile = useIsMobile()\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n  const placeholderTimerRef = useRef<NodeJS.Timeout | null>(null)\n  const imageInputRef = useRef<HTMLInputElement>(null)\n  const isMobileDevice_ = isMobileDevice()\n  const onboardingAgentPromptArmedRef = useRef(false)\n  const onboardingTypingTimerRefs = useRef<number[]>([])\n\n  const applyTypedText = useCallback((value: string) => {\n    setText(value)\n\n    const textarea = textareaRef.current\n    if (!textarea) {\n      return\n    }\n    window.requestAnimationFrame(() => {\n      textarea.style.height = 'auto'\n      const newHeight = Math.min(textarea.scrollHeight, 240)\n      textarea.style.height = `${newHeight}px`\n    })\n  }, [])\n\n  // 拖拽传感器配置（仅桌面端）\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8, // 移动8px后才开始拖拽，避免误触\n      },\n    })\n  )\n\n\n  // 添加输入到历史记录\n  function addToHistory(input: string) {\n    if (!input.trim()) return\n    \n    const newHistory = [input, ...(inputHistory || []).filter(item => item !== input)]\n    // 限制历史记录数量为50条\n    const limitedHistory = newHistory.slice(0, 50)\n    setInputHistory(limitedHistory)\n  }\n\n  // 处理历史记录导航\n  function navigateHistory(direction: 'up' | 'down', currentText: string) {\n    if (!inputHistory || inputHistory.length === 0) return\n\n    let newIndex: number\n    if (direction === 'up') {\n      // 保存当前输入内容（第一次向上时）\n      if (historyIndex === -1) {\n        setTempInput(currentText)\n      }\n      newIndex = historyIndex + 1\n      if (newIndex >= inputHistory.length) {\n        newIndex = inputHistory.length - 1\n      }\n    } else {\n      newIndex = historyIndex - 1\n      if (newIndex < -1) {\n        newIndex = -1\n      }\n    }\n\n    setHistoryIndex(newIndex)\n\n    if (newIndex === -1) {\n      // 恢复到原本输入的内容\n      setText(tempInput)\n    } else {\n      setText(inputHistory[newIndex])\n    }\n  }\n\n  // 移除关联文件\n  function removeLinkedFile() {\n    setLinkedResource(null)\n    setChatLinkedResource(null)\n  }\n\n  function removeImage(id: string) {\n    setAttachedImages(prev => prev.filter(img => img.id !== id))\n  }\n\n  function removeQuote() {\n    clearPendingQuote()\n  }\n\n  async function handleSelectLocalImages() {\n    try {\n      // 移动端使用 HTML5 file input\n      if (isMobileDevice_) {\n        imageInputRef.current?.click()\n        return\n      }\n\n      // PC端使用 Tauri dialog\n      const { open } = await import('@tauri-apps/plugin-dialog')\n      const selected = await open({\n        multiple: true,\n        filters: [{\n          name: 'Images',\n          extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']\n        }]\n      })\n\n      if (selected && Array.isArray(selected)) {\n        const newImages: ImageAttachment[] = selected.map((path) => ({\n          id: `local-${Date.now()}-${Math.random()}`,\n          url: convertFileSrc(path),\n          name: path.split('/').pop() || path,\n          source: 'file' as const\n        }))\n        \n        setAttachedImages(prev => [...prev, ...newImages])\n      }\n    } catch (error) {\n      console.error('Failed to select files:', error)\n    }\n  }\n\n  // 移动端图片选择，交给系统决定从相册还是相机获取\n  async function handleSelectFromGallery() {\n    if (isMobileDevice_) {\n      if (imageInputRef.current) {\n        imageInputRef.current.removeAttribute('capture')\n        imageInputRef.current.click()\n      }\n    }\n  }\n\n  // 处理移动端文件选择\n  async function handleImageInputChange(event: React.ChangeEvent<HTMLInputElement>) {\n    try {\n      const files = event.target.files\n      if (!files || files.length === 0) return\n\n      const newImages: ImageAttachment[] = []\n      for (let i = 0; i < files.length; i++) {\n        const file = files[i]\n        const url = URL.createObjectURL(file)\n        newImages.push({\n          id: `local-${Date.now()}-${Math.random()}`,\n          url,\n          name: file.name,\n          source: 'file' as const\n        })\n      }\n\n      setAttachedImages(prev => [...prev, ...newImages])\n      \n      // 重置 input\n      event.target.value = ''\n    } catch (error) {\n      console.error('Error in handleImageInputChange:', error)\n    }\n  }\n\n  async function handlePaste(e: React.ClipboardEvent) {\n    const items = e.clipboardData?.items\n    if (!items) return\n\n    const imageItems = Array.from(items).filter(item => item.type.startsWith('image/'))\n    if (imageItems.length === 0) return\n\n    e.preventDefault()\n\n    const newImages: ImageAttachment[] = []\n    for (const item of imageItems) {\n      const blob = item.getAsFile()\n      if (!blob) continue\n\n      try {\n        const arrayBuffer = await blob.arrayBuffer()\n        const uint8Array = new Uint8Array(arrayBuffer)\n        const fileName = `paste-${Date.now()}-${Math.random().toString(36).substring(7)}.png`\n        const filePath = `screenshot/${fileName}`\n        \n        await writeFile(filePath, uint8Array, { baseDir: BaseDirectory.AppData })\n        \n        const fullPath = await (async () => {\n          const { appDataDir, join } = await import('@tauri-apps/api/path')\n          const appData = await appDataDir()\n          return await join(appData, filePath)\n        })()\n\n        newImages.push({\n          id: `paste-${Date.now()}-${Math.random()}`,\n          url: convertFileSrc(fullPath),\n          name: fileName,\n          source: 'paste'\n        })\n      } catch (error) {\n        console.error('Failed to save pasted image:', error)\n      }\n    }\n\n    if (newImages.length > 0) {\n      setAttachedImages(prev => [...prev, ...newImages])\n    }\n  }\n\n  // 处理发送后的清理工作\n  function handleSent() {\n    if (onboardingAgentPromptArmedRef.current) {\n      onboardingAgentPromptArmedRef.current = false\n      emitter.emit('onboarding-step-complete', { step: 'ai-polish' })\n    }\n    addToHistory(text)\n    setText('')\n    setHistoryIndex(-1)\n    setAttachedImages([])\n    clearPendingQuote()\n    const textarea = document.querySelector('textarea')\n    if (textarea) {\n      textarea.style.height = 'auto'\n    }\n  }\n\n  // 获取输入框占位符\n  async function genInputPlaceholder() {\n    if (!primaryModel) return\n    if (trashState) return\n    const lastClearIndex = chats.findLastIndex(item => item.type === 'clear')\n    const chatsAfterClear = chats.slice(lastClearIndex + 1)\n    const request_content = `\n      ${chatsAfterClear.slice(0, 5).map(item => item.content?.slice(0, 60)).join(';\\n\\n')}\n    `.trim()\n    // 使用 fetchAiQuickPrompts 获取4条提示词\n    const prompts = await fetchAiQuickPrompts(request_content)\n    // 发送事件给 chat-empty 组件，显示前3条\n    if (prompts.length >= 3) {\n      emitter.emit('ai-prompts-generated', prompts)\n    }\n    // 取第4条作为 placeholder\n    if (prompts.length >= 4 && prompts[3]?.text) {\n      setPlaceholder(prompts[3].text + ' [Tab]')\n    }\n  }\n\n  // 防抖的 placeholder 生成函数，延迟 1.5 秒执行，只执行最后一次\n  const debouncedGenPlaceholder = useCallback(() => {\n    // 清除之前的定时器\n    if (placeholderTimerRef.current) {\n      clearTimeout(placeholderTimerRef.current)\n    }\n    \n    // 设置新的定时器\n    placeholderTimerRef.current = setTimeout(() => {\n      genInputPlaceholder()\n    }, 1500) // 1.5秒延迟\n  }, [primaryModel, marks, chats, trashState, t])\n\n\n  // 插入占位符\n  function insertPlaceholder() {\n    if (placeholder.includes('[Tab]')) {\n      setText(placeholder.replace('[Tab]', ''))\n      setPlaceholder('')\n    }\n  }\n\n  // 处理拖拽结束（底部工具栏）\n  const handleDragEnd = useCallback((event: DragEndEvent) => {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const enabledItems = chatToolbarConfigPc.filter(item => item.enabled)\n      const oldIndex = enabledItems.findIndex((item) => item.id === active.id)\n      const newIndex = enabledItems.findIndex((item) => item.id === over.id)\n\n      const reorderedItems = arrayMove(enabledItems, oldIndex, newIndex)\n      const allItems = [...chatToolbarConfigPc]\n\n      reorderedItems.forEach((item, index) => {\n        const globalIndex = allItems.findIndex(i => i.id === item.id)\n        if (globalIndex !== -1) {\n          allItems[globalIndex] = { ...item, order: enabledItems[0].order + index }\n        }\n      })\n\n      setChatToolbarConfigPc(allItems)\n    }\n  }, [chatToolbarConfigPc, setChatToolbarConfigPc])\n\n  // 使用 useMemo 优化工具栏项过滤 - 显示底部工具栏（排除 newChat）\n  const bottomToolbarItems = useMemo(() => {\n    return chatToolbarConfigPc\n      .filter(item => item.enabled && item.id !== 'newChat')\n      .sort((a, b) => a.order - b.order)\n  }, [chatToolbarConfigPc])\n\n  useEffect(() => {\n    // 如果有 marks，生成 AI 提示词作为 placeholder\n    if (marks.length > 0) {\n      genInputPlaceholder()\n    } else {\n      setPlaceholder(t('record.chat.input.placeholder.default'))\n    }\n  }, [primaryModel, marks, t])\n\n  useEffect(() => {\n    emitter.on('revertChat', (event: unknown) => {\n      setText(event as string)\n    })\n    emitter.on('fileSelected', (event: unknown) => {\n      setLinkedResource(event as MarkdownFile)\n      setChatLinkedResource(event as MarkdownFile)\n    })\n    emitter.on('folderSelected', (event: unknown) => {\n      setLinkedResource(event as LinkedFolder)\n      setChatLinkedResource(event as LinkedFolder)\n    })\n    emitter.on('insert-quote', (event: unknown) => {\n      const data = event as PendingQuote\n      setPendingQuote(data)\n      // 延迟聚焦到输入框\n      setTimeout(() => {\n        textareaRef.current?.focus()\n      }, 50)\n      // 触发防抖的 placeholder 重新生成\n      debouncedGenPlaceholder()\n    })\n    emitter.on('quick-prompt-insert', (prompt: string) => {\n      setText(prompt)\n      textareaRef.current?.focus()\n    })\n    emitter.on('ai-placeholder-generated', (event: unknown) => {\n      const promptText = event as string\n      if (promptText) {\n        setPlaceholder(promptText)\n      }\n    })\n    return () => {\n      onboardingTypingTimerRefs.current.forEach((timerId) => window.clearTimeout(timerId))\n      onboardingTypingTimerRefs.current = []\n      emitter.off('revertChat')\n      emitter.off('fileSelected')\n      emitter.off('folderSelected')\n      emitter.off('insert-quote')\n      emitter.off('quick-prompt-insert')\n      emitter.off('ai-placeholder-generated')\n    }\n  }, [debouncedGenPlaceholder, setPendingQuote])\n\n  useEffect(() => {\n    if (!onboardingPromptDraft) {\n      return\n    }\n\n    onboardingAgentPromptArmedRef.current = true\n    onboardingTypingTimerRefs.current.forEach((timerId) => window.clearTimeout(timerId))\n    onboardingTypingTimerRefs.current = []\n    setText('')\n    setTimeout(() => {\n      textareaRef.current?.focus()\n    }, 50)\n\n    const frames = buildTypingFrames(onboardingPromptDraft, 2)\n    frames.forEach((frame, index) => {\n      const timerId = window.setTimeout(() => {\n        applyTypedText(frame)\n        if (index === frames.length - 1) {\n          onboardingTypingTimerRefs.current = []\n          setOnboardingPromptDraft(null)\n        }\n      }, 160 + index * 42)\n      onboardingTypingTimerRefs.current.push(timerId)\n    })\n  }, [applyTypedText, onboardingPromptDraft, setOnboardingPromptDraft])\n\n  // 生成文件的行号预览（用于 AI 对话）\n  async function generateFilePreview(filePath: string, isCustom: boolean): Promise<string> {\n    try {\n      // 检查文件是否存在\n      const fileExists = isCustom\n        ? await exists(filePath)\n        : await exists(filePath, { baseDir: BaseDirectory.AppData })\n\n      if (!fileExists) {\n        return `文件 ${filePath.split('/').pop() || filePath} 不存在或已被删除`\n      }\n\n      let content: string\n      if (isCustom) {\n        content = await readTextFile(filePath)\n      } else {\n        content = await readTextFile(filePath, { baseDir: BaseDirectory.AppData })\n      }\n\n      const lines = content.split('\\n')\n      const previewLines = lines.slice(0, 100).map((line, index) => {\n        const lineNum = index + 1\n        const preview = line.length > 60 ? line.slice(0, 60) + '...' : line\n        return `${String(lineNum).padStart(4)} | ${preview}`\n      })\n\n      const totalLines = lines.length\n      const truncatedNote = totalLines > 100 ? `\\n... (共 ${totalLines} 行，后 ${totalLines - 100} 行省略)` : ''\n\n      return `已关联文件：${filePath.split('/').pop() || filePath}\n你可以使用 replace_editor_content 工具通过行号修改内容。\n\n行号预览：\n\\`\\`\\`\n${previewLines.join('\\n')}\n\\`\\`\\`${truncatedNote}\n\n使用示例：\n- 修改第 4-5 行：replace_editor_content({startLine: 4, endLine: 5, replaceContent: \"新内容\"})\n- 替换第 10 行的特定内容：replace_editor_content({startLine: 10, endLine: 10, replaceContent: \"新内容\"})\n`\n    } catch (error) {\n      console.error('生成文件预览失败:', error)\n      return `已关联文件：${filePath.split('/').pop() || filePath}\n（无法读取文件内容）`\n    }\n  }\n\n  // 自动关联当前打开的 markdown 文件或文件夹\n  useEffect(() => {\n    async function linkCurrentResource() {\n      if (!activeFilePath) {\n        setLinkedResource(null)\n        setChatLinkedResource(null)\n        setLinkedResourcePreview(null)\n        return\n      }\n\n      const workspace = await getWorkspacePath()\n\n      // 检查是否是支持的文件类型（包括 markdown、代码文件等）\n      if (activeFilePath.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template)$/i)) {\n        // 文件关联逻辑\n        const fileName = activeFilePath.split('/').pop() || activeFilePath\n\n        // 构建完整路径\n        let fullPath: string\n        if (workspace.isCustom) {\n          const pathParts = activeFilePath.split('/')\n          fullPath = workspace.path + '/' + pathParts.join('/')\n        } else {\n          fullPath = activeFilePath\n        }\n\n        const resource = {\n          name: fileName,\n          path: fullPath,\n          relativePath: activeFilePath\n        }\n        setLinkedResource(resource)\n        setChatLinkedResource(resource)\n\n        // 生成并设置文件预览\n        const preview = await generateFilePreview(fullPath, workspace.isCustom)\n        setLinkedResourcePreview(preview)\n      } else if (!activeFilePath.includes('.')) {\n        // 文件夹关联逻辑 - 只有当路径不包含 . 时才可能是文件夹\n        const folderName = activeFilePath.split('/').pop() || activeFilePath\n\n        // 构建完整路径\n        let fullPath: string\n        if (workspace.isCustom) {\n          const pathParts = activeFilePath.split('/')\n          fullPath = workspace.path + '/' + pathParts.join('/')\n        } else {\n          fullPath = activeFilePath\n        }\n\n        // 计算文件夹中的文件数量和索引状态\n        const { collectMarkdownFiles } = await import('@/lib/files')\n        const files = await collectMarkdownFiles(activeFilePath)\n        const { vectorIndexedFiles } = useArticleStore.getState()\n        const indexedCount = files.filter(f =>\n          vectorIndexedFiles.has(f.path)\n        ).length\n\n        // 只有在有索引文件时才关联文件夹\n        if (indexedCount > 0) {\n          const resource = {\n            name: folderName,\n            path: fullPath,\n            relativePath: activeFilePath,\n            fileCount: files.length,\n            indexedCount: indexedCount\n          }\n          setLinkedResource(resource)\n          setChatLinkedResource(resource)\n          // 文件夹不生成行号预览\n          setLinkedResourcePreview(null)\n        } else {\n          // 没有索引文件，清除关联\n          setLinkedResource(null)\n          setChatLinkedResource(null)\n          setLinkedResourcePreview(null)\n        }\n      } else {\n        // 不支持的文件类型（如 .docx, .pdf 等），不进行关联\n        setLinkedResource(null)\n        setChatLinkedResource(null)\n        setLinkedResourcePreview(null)\n      }\n    }\n\n    linkCurrentResource()\n  }, [activeFilePath])\n\n  // 当关联文件变化时，触发防抖的 placeholder 重新生成\n  useEffect(() => {\n    if (linkedResource) {\n      debouncedGenPlaceholder()\n    }\n  }, [linkedResource, debouncedGenPlaceholder])\n\n  return (\n    <footer id=\"onboarding-target-chat-input\" className=\"flex flex-col w-full p-1 justify-between items-center\">\n      {/* 移动端图片选择 */}\n      {isMobileDevice_ && (\n        <input\n          ref={imageInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          multiple\n          onChange={handleImageInputChange}\n          className=\"hidden\"\n        />\n      )}\n      <LinkedFileDisplay\n        linkedResource={linkedResource}\n        onFileRemove={removeLinkedFile}\n      />\n      <div className=\"group relative flex flex-col border rounded-xl z-10 gap-1 p-1 w-full bg-background focus-within:border-primary transition-colors overflow-hidden\">\n        {loading && (\n          <ShineBorder\n            borderWidth={1}\n            duration={5}\n            shineColor={[\"#FF6B6B\", \"#4ECDC4\", \"#45B7D1\", \"#FFA07A\"]}\n          />\n        )}\n        {pendingQuote && (\n          <QuoteDisplay quoteData={pendingQuote} onRemove={removeQuote} />\n        )}\n        <ImageAttachments images={attachedImages} onRemove={removeImage} />\n        <div className=\"relative w-full flex items-start\">\n          <Textarea\n            ref={textareaRef}\n            className=\"flex-1 p-2 relative border-none text-xs placeholder:text-sm md:placeholder:text-sm md:text-sm focus-visible:ring-0 shadow-none min-h-[36px] max-h-[240px] resize-none overflow-y-auto\"\n            rows={1}\n            disabled={!primaryModel || loading}\n            value={text}\n            onChange={(e) => {\n              setText(e.target.value)\n              const textarea = e.target\n              textarea.style.height = 'auto'\n              const newHeight = Math.min(textarea.scrollHeight, 240)\n              textarea.style.height = `${newHeight}px`\n            }}\n            placeholder={placeholder}\n            onKeyDown={(e) => {\n              const textarea = e.target as HTMLTextAreaElement\n              const cursorPosition = textarea.selectionStart\n              const isAtStart = cursorPosition === 0\n              const isAtEnd = cursorPosition === text.length\n\n              if (e.key === \"Enter\" && !isComposing && !e.shiftKey && e.keyCode === 13) {\n                e.preventDefault()\n                chatSendRef.current?.sendChat()\n              }\n              if (e.key === \"Tab\") {\n                e.preventDefault()\n                insertPlaceholder()\n              }\n              if (e.key === \"ArrowUp\" && !isComposing) {\n                if (isAtStart) {\n                  e.preventDefault()\n                  navigateHistory('up', text)\n                } else if (isAtEnd) {\n                  e.preventDefault()\n                  // 移动光标到开头\n                  textarea.setSelectionRange(0, 0)\n                }\n              }\n              if (e.key === \"ArrowDown\" && !isComposing) {\n                if (isAtStart) {\n                  e.preventDefault()\n                  navigateHistory('down', text)\n                } else if (isAtEnd) {\n                  e.preventDefault()\n                  // 移动光标到开头\n                  textarea.setSelectionRange(0, 0)\n                }\n              }\n              if (e.key === \"Backspace\") {\n                if (text === '') {\n                  setPlaceholder(t('record.chat.input.placeholder.default'))\n                }\n              }\n            }}\n            onCompositionStart={() => setIsComposing(true)}\n            onCompositionEnd={() => setTimeout(() => {\n              setIsComposing(false)\n            }, 0)}\n            onPaste={handlePaste}\n          />\n        </div>\n        \n        <div className=\"flex justify-between items-center w-full\">\n          <div className=\"flex-1\">\n            {/* 可拖拽排序的按钮容器（桌面端）或普通容器（移动端） */}\n            {!isMobile ? (\n              <DndContext\n                sensors={sensors}\n                collisionDetection={closestCenter}\n                onDragEnd={handleDragEnd}\n              >\n                <SortableContext\n                  items={bottomToolbarItems.map(item => item.id)}\n                  strategy={horizontalListSortingStrategy}\n                >\n                  <div className=\"flex overflow-x-auto scrollbar-hide md:overflow-visible\">\n                    {bottomToolbarItems.map(item => (\n                      <SortableToolbarItem\n                        key={item.id}\n                        id={item.id}\n                      />\n                    ))}\n                  </div>\n                </SortableContext>\n              </DndContext>\n            ) : (\n              <div className=\"flex overflow-x-auto scrollbar-hide md:overflow-visible gap-1\">\n                <ChatToolsDrawer />\n              </div>\n            )}\n          </div>\n          <div className=\"flex items-center justify-end gap-2 pr-1\">\n            <TooltipButton\n              variant=\"link\"\n              size=\"sm\"\n              icon={<ImageIcon className=\"size-4\" />}\n              tooltipText={t('record.chat.input.attachImage')}\n              onClick={isMobile ? handleSelectFromGallery : handleSelectLocalImages}\n              disabled={!primaryModel || loading}\n            />\n            <ChatSend inputValue={text} onSent={handleSent} linkedResource={linkedResource} attachedImages={attachedImages} quoteData={pendingQuote} ref={chatSendRef} />\n          </div>\n        </div>\n\n      </div>\n    </footer>\n  )\n})\nChatInput.displayName = 'ChatInput'\n"
  },
  {
    "path": "src/app/core/main/chat/chat-preview.tsx",
    "content": "'use client'\nimport useSettingStore from \"@/stores/setting\";\nimport React, { useEffect, useRef, useState, useCallback } from 'react';\nimport { useTheme } from 'next-themes'\nimport MarkdownIt from 'markdown-it';\nimport hljs from 'highlight.js/lib/core';\nimport javascript from 'highlight.js/lib/languages/javascript';\nimport typescript from 'highlight.js/lib/languages/typescript';\nimport bash from 'highlight.js/lib/languages/bash';\nimport json from 'highlight.js/lib/languages/json';\nimport xml from 'highlight.js/lib/languages/xml';\nimport css from 'highlight.js/lib/languages/css';\nimport 'highlight.js/styles/github.min.css';\nimport './chat.css';\nimport { advanceStreamingSmoother } from './streaming-smoother';\n\ntype ThemeType = 'light' | 'dark' | 'system';\n\ntype ChatPreviewProps = {\n  text: string;\n  streaming?: boolean; // 是否为流式内容\n};\n\nconst MIN_RENDER_INTERVAL_MS = 33;\n\nexport default function ChatPreview({text, streaming = false}: ChatPreviewProps) {\n  const previewRef = useRef<HTMLDivElement>(null);\n  const { theme } = useTheme()\n  const [mdTheme, setMdTheme] = useState<ThemeType>('light')\n  const { codeTheme, contentTextScale } = useSettingStore()\n  const [htmlContent, setHtmlContent] = useState<string>('');\n  const [, setDisplayedText] = useState<string>('');\n  const animationRef = useRef<number | null>(null);\n  const displayedTextRef = useRef('');\n  const targetTextRef = useRef('');\n  const carryCharsRef = useRef(0);\n  const lastFrameTimeRef = useRef<number | null>(null);\n  const lastRenderTimeRef = useRef(0);\n  const md = useRef<MarkdownIt | null>(null);\n\n  useEffect(() => {\n    hljs.registerLanguage('javascript', javascript);\n    hljs.registerLanguage('typescript', typescript);\n    hljs.registerLanguage('bash', bash);\n    hljs.registerLanguage('json', json);\n    hljs.registerLanguage('html', xml);\n    hljs.registerLanguage('css', css);\n  }, []);\n  \n  useEffect(() => {\n    md.current = new MarkdownIt({\n      html: true,\n      linkify: true,\n      typographer: true,\n      highlight: function (str, lang): string {\n        if (lang && hljs.getLanguage(lang)) {\n          try {\n            const themeClass = mdTheme === 'dark' ? 'hljs-dark' : 'hljs-light';\n            return `<pre class=\"hljs ${themeClass}\"><code>` +\n              hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +\n            '</code></pre>';\n          } catch {}\n        }\n        // 使用通用高亮\n        const themeClass = mdTheme === 'dark' ? 'hljs-dark' : 'hljs-light';\n        return `<pre class=\"hljs ${themeClass}\"><code>` +\n          (md.current ? md.current.utils.escapeHtml(str) : str) +\n          '</code></pre>';\n      }\n    });\n\n    md.current.renderer.rules.link_open = function (tokens, idx, options, _env, self) {\n      tokens[idx].attrSet('target', '_blank');\n      tokens[idx].attrSet('rel', 'noopener noreferrer');\n      return self.renderToken(tokens, idx, options);\n    }\n\n    if (displayedTextRef.current) {\n      setHtmlContent(md.current.render(displayedTextRef.current));\n    } else {\n      setHtmlContent('');\n    }\n  }, [mdTheme]);\n\n  const renderDisplayedText = useCallback((nextText: string, force = false) => {\n    displayedTextRef.current = nextText;\n\n    if (!force) {\n      const now = performance.now();\n      if (now - lastRenderTimeRef.current < MIN_RENDER_INTERVAL_MS) {\n        return;\n      }\n      lastRenderTimeRef.current = now;\n    } else {\n      lastRenderTimeRef.current = performance.now();\n    }\n\n    setDisplayedText(nextText);\n    if (md.current) {\n      setHtmlContent(md.current.render(nextText));\n    } else {\n      setHtmlContent(nextText);\n    }\n  }, []);\n\n  const stopAnimation = useCallback(() => {\n    if (animationRef.current !== null) {\n      cancelAnimationFrame(animationRef.current);\n      animationRef.current = null;\n    }\n    lastFrameTimeRef.current = null;\n    carryCharsRef.current = 0;\n  }, []);\n\n  const tickStreaming = useCallback((frameTime: number) => {\n    const lastFrameTime = lastFrameTimeRef.current ?? frameTime;\n    const elapsedMs = frameTime - lastFrameTime;\n    lastFrameTimeRef.current = frameTime;\n\n    const next = advanceStreamingSmoother(\n      {\n        carryChars: carryCharsRef.current,\n        displayedLength: displayedTextRef.current.length,\n      },\n      targetTextRef.current.length,\n      elapsedMs,\n    );\n\n    carryCharsRef.current = next.carryChars;\n\n    if (next.charsAdded > 0) {\n      renderDisplayedText(\n        targetTextRef.current.slice(0, next.displayedLength),\n      );\n    }\n\n    if (next.displayedLength >= targetTextRef.current.length) {\n      animationRef.current = null;\n      lastFrameTimeRef.current = null;\n      carryCharsRef.current = 0;\n      renderDisplayedText(targetTextRef.current, true);\n      return;\n    }\n\n    animationRef.current = requestAnimationFrame(tickStreaming);\n  }, [renderDisplayedText]);\n\n  const ensureStreamingAnimation = useCallback(() => {\n    if (animationRef.current !== null) {\n      return;\n    }\n    lastFrameTimeRef.current = null;\n    animationRef.current = requestAnimationFrame(tickStreaming);\n  }, [tickStreaming]);\n\n  // 处理流式内容更新\n  useEffect(() => {\n    if (!streaming) {\n      stopAnimation();\n      targetTextRef.current = text;\n      renderDisplayedText(text, true);\n      return;\n    }\n\n    targetTextRef.current = text;\n\n    if (text.length < displayedTextRef.current.length) {\n      stopAnimation();\n      renderDisplayedText(text, true);\n      return;\n    }\n\n    if (text.length === displayedTextRef.current.length) {\n      if (text !== displayedTextRef.current) {\n        renderDisplayedText(text, true);\n      }\n      return;\n    }\n\n    ensureStreamingAnimation();\n  }, [text, streaming, ensureStreamingAnimation, renderDisplayedText, stopAnimation]);\n\n  // 清理动画\n  useEffect(() => {\n    return () => {\n      stopAnimation();\n    };\n  }, [stopAnimation]);\n\n  useEffect(() => {\n    if (theme === 'system') {\n      if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n        setMdTheme('dark')\n      } else {\n        setMdTheme('light')\n      }\n    } else {\n      setMdTheme(theme as ThemeType)\n    }\n  }, [theme])\n\n  useEffect(() => {\n    // 加载Markdown主题样式\n    const link = document.createElement('link');\n    link.id = 'markdown-theme-style';\n    link.rel = 'stylesheet';\n    switch (theme) {\n      case 'dark':\n        link.href = '/markdown/github-markdown-dark.css';\n        break;\n      case 'light':\n        link.href = '/markdown/github-markdown-light.css';\n        break;\n      case 'system':\n        if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n          link.href = '/markdown/github-markdown-dark.css';\n        } else {\n          link.href = '/markdown/github-markdown-light.css';\n        }\n        break;\n    }\n    \n    const existingLink = document.getElementById('markdown-theme-style');\n    if (existingLink) document.head.removeChild(existingLink);\n    document.head.appendChild(link);\n\n    // 监听系统主题变化\n    const matchMedia = window.matchMedia('(prefers-color-scheme: dark)')\n    const handler = () => {\n      if (theme === 'system') {\n        const themeValue = matchMedia.matches ? 'dark' : 'light'\n        setMdTheme(themeValue)\n      }\n    }\n    matchMedia.addEventListener('change', handler)\n    return () => {\n      matchMedia.removeEventListener('change', handler)\n    }\n  }, [theme])\n  \n  // 应用正文文字大小缩放\n  useEffect(() => {\n    if (previewRef.current) {\n      previewRef.current.style.fontSize = `${contentTextScale + 15}%`\n    }\n  }, [contentTextScale])\n\n  // 根据主题选择样式\n  const getThemeClass = () => {\n    if (mdTheme === 'dark') {\n      return 'markdown-body markdown-dark';\n    }\n    return 'markdown-body';\n  };\n\n  // 应用高亮样式\n  const getHighlightStyle = () => {\n    return codeTheme || 'github';\n  };\n\n  // 检测是否为 macOS\n  const isMacOS = () => {\n    if (typeof window === 'undefined') return false;\n    return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);\n  };\n\n  // 处理文本选中后的拖拽（仅 macOS）\n  const handleDragStart = (e: React.DragEvent) => {\n    // 非 macOS 系统直接阻止拖拽\n    if (!isMacOS()) {\n      e.preventDefault();\n      return;\n    }\n\n    const selection = window.getSelection()\n    const selectedText = selection?.toString().trim()\n\n    if (selectedText) {\n      // 设置拖拽数据为选中的文本\n      e.dataTransfer.setData('text/plain', selectedText)\n      e.dataTransfer.effectAllowed = 'copy'\n\n      // 创建自定义拖拽预览图像，只显示选中的文本\n      const dragPreview = document.createElement('div')\n      dragPreview.style.position = 'absolute'\n      dragPreview.style.left = '-9999px'\n      dragPreview.style.padding = '8px 12px'\n      dragPreview.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'\n      dragPreview.style.color = 'white'\n      dragPreview.style.borderRadius = '4px'\n      dragPreview.style.fontSize = '14px'\n      dragPreview.style.maxWidth = '300px'\n      dragPreview.style.overflowWrap = 'break-word'\n      dragPreview.textContent = selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText\n\n      document.body.appendChild(dragPreview)\n      e.dataTransfer.setDragImage(dragPreview, 0, 0)\n\n      // 拖拽结束后移除预览元素\n      setTimeout(() => {\n        document.body.removeChild(dragPreview)\n      }, 0)\n    } else {\n      // 如果没有选中文本，阻止拖拽\n      e.preventDefault()\n    }\n  }\n\n  // 没有内容时不渲染\n  if (!text || !text.trim()) {\n    return null\n  }\n\n  return (\n    <div className=\"flex-1 max-w-[calc(100vw-30px)] md:max-w-[calc(100vw-440px)]\">\n      <div \n        ref={previewRef}\n        className={getThemeClass()}\n        dangerouslySetInnerHTML={{ __html: htmlContent }}\n        data-highlight-style={getHighlightStyle()}\n        draggable={isMacOS()}\n        onDragStart={handleDragStart}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/chat/chat-send.tsx",
    "content": "\"use client\"\nimport { Send, Square } from \"lucide-react\"\nimport useSettingStore from \"@/stores/setting\"\nimport useChatStore from \"@/stores/chat\"\nimport useTagStore from \"@/stores/tag\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { useImperativeHandle, forwardRef, useRef, useEffect } from \"react\"\nimport { useTranslations } from \"next-intl\"\nimport useVectorStore from \"@/stores/vector\"\nimport { getContextForQuery, getContextForQueryInFolder } from '@/lib/rag'\nimport { invoke } from \"@tauri-apps/api/core\"\nimport { LinkedResource, isLinkedFolder } from \"@/lib/files\"\nimport { readTextFile } from \"@tauri-apps/plugin-fs\"\nimport { getFilePathOptions, getWorkspacePath } from \"@/lib/workspace\"\nimport { AgentHandler } from \"@/lib/agent/agent-handler\"\nimport { getToolByName } from \"@/lib/agent/tools\"\nimport { getSessionApprovalScope, matchesSessionApproval } from \"@/lib/agent/session-approval\"\nimport { ImageAttachment } from \"./image-attachments\"\nimport type { RagSource } from \"@/lib/rag\"\n\ninterface QuoteData {\n  quote: string\n  fullContent: string\n  fileName: string\n  startLine: number\n  endLine: number\n  from: number\n  to: number\n  articlePath: string\n}\n\ninterface ChatSendProps {\n  inputValue: string;\n  onSent?: () => void;\n  linkedResource?: LinkedResource | null;\n  attachedImages?: ImageAttachment[];\n  quoteData?: QuoteData | null;\n}\n\nexport const ChatSend = forwardRef<{ sendChat: () => void }, ChatSendProps>(({ inputValue, onSent, linkedResource, attachedImages = [], quoteData = null }, ref) => {\n  const { primaryModel } = useSettingStore()\n  const { currentTagId } = useTagStore()\n  const {\n    insert,\n    loading,\n    setLoading,\n    saveChat,\n    setAgentState,\n    maybeCondense,\n    linkedResourcePreview,\n  } = useChatStore()\n  const { isRagEnabled } = useVectorStore()\n  const abortControllerRef = useRef<AbortController | null>(null)\n  const agentHandlerRef = useRef<AgentHandler | null>(null)\n  const t = useTranslations()\n\n  // 跟踪上一次的 loading 状态\n  const wasLoadingRef = useRef(false)\n\n  // 在 AI 响应完成后，触发压缩检查\n  useEffect(() => {\n    if (wasLoadingRef.current && !loading) {\n      // loading 从 true 变为 false，AI 响应完成\n      // 异步触发，不等待完成\n      maybeCondense()\n    }\n    wasLoadingRef.current = loading\n  }, [loading, maybeCondense])\n\n  // RAG 关键词停用词过滤\n  // 过滤掉没有实际检索意义的虚词\n  const filterRAGKeywords = (keywords: {text: string, weight: number}[]) => {\n    const stopWords = new Set([\n      // 中文虚词/系动词\n      '的', '了', '是', '在', '有', '和', '就', '不', '人', '都', '一', '一个',\n      '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看',\n      '好', '自己', '这', '那', '里', '就是', '为', '与', '之', '用', '可以',\n      '但', '而', '或', '及', '等', '对', '把', '被', '让', '给', '从', '向',\n      '什么', '怎么', '怎样', '如何', '为什么', '哪些', '多少',\n\n      // 英文停用词\n      'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',\n      'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',\n      'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',\n      'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those',\n      'what', 'how', 'why', 'where', 'when', 'who', 'which'\n    ])\n\n    return keywords.filter(k => {\n      const text = k.text.trim().toLowerCase()\n      // 过滤掉停用词和单字\n      return !stopWords.has(text) && text.length > 1\n    })\n  }\n\n  const shouldCarryUserHistoryForAgent = (input: string) => {\n    const normalized = input.trim().toLowerCase()\n    if (!normalized) {\n      return false\n    }\n\n    return /^(继续|接着|然后|再来|再生成|再做|顺便|另外|刚才|基于刚才|在此基础上|那个|这个|它|继续用|再用)/.test(normalized)\n      || /(继续|接着|然后|再来|再生成|再做|顺便|另外|刚才|基于刚才|在此基础上|那个|这个|它)/.test(normalized)\n  }\n\n  const buildPartialSuccessContent = (result: string, toolCalls: { result?: { success?: boolean; data?: any; error?: string } }[]) => {\n    const generatedOutputFiles = toolCalls.flatMap((toolCall) => {\n      const outputFiles = toolCall.result?.data?.output_files\n      return Array.isArray(outputFiles) ? outputFiles : []\n    })\n\n    const uniqueOutputFiles = Array.from(new Set(generatedOutputFiles.filter((file): file is string => typeof file === 'string' && file.trim().length > 0)))\n    if (uniqueOutputFiles.length === 0) {\n      return null\n    }\n\n    const failedToolCall = [...toolCalls].reverse().find((toolCall) => toolCall.result?.success === false)\n    const failureMessage = failedToolCall?.result?.error || result\n\n    return [\n      `已成功生成文件：`,\n      uniqueOutputFiles.map((file) => `- ${file}`).join('\\n'),\n      '',\n      `后续校验或附加步骤失败：${failureMessage}`,\n    ].join('\\n')\n  }\n\n  const sanitizeAgentFinalContent = (content: string) => {\n    const trimmed = content.trim()\n    if (!trimmed) {\n      return trimmed\n    }\n\n    const markers = ['\\nThought:', '\\nAction:', '\\nAction Input:']\n    let cutoff = trimmed.length\n\n    for (const marker of markers) {\n      const index = trimmed.indexOf(marker)\n      if (index !== -1) {\n        cutoff = Math.min(cutoff, index)\n      }\n    }\n\n    const leadingActionIndex = trimmed.search(/^(Thought:|Action:|Action Input:)/)\n    if (leadingActionIndex === 0) {\n      const finalAnswerMatch = trimmed.match(/Final Answer[:：]\\s*([\\s\\S]*)/i)\n      if (finalAnswerMatch) {\n        return finalAnswerMatch[1].trim()\n      }\n    }\n\n    return trimmed.slice(0, cutoff).trim()\n  }\n\n  useImperativeHandle(ref, () => ({\n    sendChat: handleSubmit\n  }))\n\n  // Agent 确认回调 - 使用内联确认而不是弹窗\n  const requestConfirmation = (\n    toolName: string,\n    params: Record<string, any>,\n    context?: {\n      originalContent?: string\n      modifiedContent?: string\n      filePath?: string\n    }\n  ): Promise<boolean> => {\n    const tool = getToolByName(toolName)\n    const sessionApprovalScope = getSessionApprovalScope(toolName, tool, params)\n    const canApproveForSession = !!sessionApprovalScope\n\n    const currentChatState = useChatStore.getState()\n    const activeConversationId = currentChatState.currentConversationId\n    const autoApproveConversationId = currentChatState.agentAutoApproveConversationId\n    const autoApproveRuntimeSkillId = currentChatState.agentAutoApproveRuntimeSkillId\n\n    if (matchesSessionApproval(\n      autoApproveConversationId,\n      activeConversationId,\n      autoApproveRuntimeSkillId,\n      sessionApprovalScope\n    )) {\n      return Promise.resolve(true)\n    }\n\n    return new Promise((resolve) => {\n      // 将确认请求保存到 store，在对话中显示\n      setAgentState({\n        pendingConfirmation: {\n          toolName,\n          params,\n          ...context,\n          canApproveForSession,\n          sessionApprovalType: sessionApprovalScope?.type,\n          sessionApprovalSkillId: sessionApprovalScope?.skillId,\n        }\n      })\n      \n      // 轮询检查用户是否已确认或取消\n      const checkInterval = setInterval(() => {\n        const currentState = useChatStore.getState()\n        \n        // 如果 pendingConfirmation 被清除，说明用户已操作\n        if (!currentState.agentState.pendingConfirmation) {\n          clearInterval(checkInterval)\n          // 如果 Agent 仍在运行，说明用户确认了\n          resolve(currentState.agentState.isRunning)\n        }\n      }, 100)\n    })\n  }\n\n  // Agent 模式处理\n  async function handleAgentMode(imageUrls: string[]) {\n    // 先创建一个占位的 AI 消息\n    const placeholderMessage = await insert({\n      tagId: currentTagId,\n      role: 'system',\n      content: '',\n      type: 'chat',\n      inserted: false,\n    })\n\n    if (!placeholderMessage) return\n\n    setAgentState({\n      activeChatId: placeholderMessage.id,\n    })\n\n    // 每次都创建新的 AgentHandler，使用当前的 placeholderMessage\n    const agentHandler = new AgentHandler({\n      activeChatId: placeholderMessage.id,\n      requestConfirmation,\n      currentQuote: quoteData\n        ? {\n            fileName: quoteData.fileName,\n            startLine: quoteData.startLine,\n            endLine: quoteData.endLine,\n            from: quoteData.from,\n            to: quoteData.to,\n            fullContent: quoteData.fullContent,\n          }\n        : undefined,\n      onFinalAnswerRender: (markdownContent) => {\n        // 检测到 Final Answer 时触发渲染\n        setAgentState({\n          activeChatId: placeholderMessage.id,\n          isFinalAnswerMode: true,\n          finalAnswerContent: markdownContent\n        })\n      },\n      formatAutoFinalAnswer: (key, values) => t(key as any, values),\n      onComplete: async (result, steps, stopped) => {\n        // 获取 Agent 执行历史，保存完整的 ReAct 步骤\n        const { agentState } = useChatStore.getState()\n        // 使用 agentState.completedSteps 而不是 steps 参数，因为 completedSteps 包含 duration 信息\n        const agentHistory = {\n          steps: agentState.completedSteps || [], // 保存完整的 ReAct 步骤（包含 thought, action, observation, duration）\n          toolCalls: agentState.toolCalls,\n          iterations: agentState.currentIteration,\n        }\n\n        // 如果是被终止的，构建包含终止信息的消息\n        let finalContent = result\n        if (stopped) {\n          // 保留已产生的步骤，并添加终止信息\n          const stepCount = agentState.completedSteps?.length || 0\n          if (stepCount > 0) {\n            // 有已完成的步骤，显示这些步骤的内容\n            finalContent = `${t('record.chat.input.stopped')}\\n\\n已完成 ${stepCount} 个步骤：\\n${agentState.completedSteps!.map((step, i) =>\n              `${i + 1}. ${step.action?.tool || '思考'}`\n            ).join('\\n')}`\n          } else {\n            // 没有已完成步骤，显示简单的终止信息\n            finalContent = t('record.chat.input.stopped')\n          }\n        }\n\n        if (!stopped) {\n          const partialSuccessContent = buildPartialSuccessContent(result, agentState.toolCalls)\n          if (partialSuccessContent && /^工具 .+执行失败：|^工具 .+执行出错：|^Error:/.test(finalContent.trim())) {\n            finalContent = partialSuccessContent\n          }\n        }\n\n        finalContent = sanitizeAgentFinalContent(finalContent)\n\n        // 获取当前消息状态，保留 ragSources 和 ragSourceDetails\n        const currentState = useChatStore.getState()\n        const currentMessage = currentState.chats.find(c => c.id === placeholderMessage.id)\n\n        // 更新占位消息，保留 RAG 相关字段\n        await saveChat({\n          id: placeholderMessage.id,\n          tagId: placeholderMessage.tagId,\n          conversationId: placeholderMessage.conversationId,\n          role: placeholderMessage.role,\n          type: placeholderMessage.type,\n          inserted: placeholderMessage.inserted,\n          createdAt: placeholderMessage.createdAt,\n          // 保留来自 currentMessage 的 RAG 相关字段\n          ragSources: currentMessage?.ragSources,\n          ragSourceDetails: currentMessage?.ragSourceDetails,\n          // 设置新的内容\n          content: finalContent,\n          agentHistory: JSON.stringify(agentHistory),\n        }, true)\n\n        // 清空 Final Answer 模式状态\n        setAgentState({\n          activeChatId: undefined,\n          isFinalAnswerMode: false,\n          finalAnswerContent: undefined\n        })\n\n        // 清空 ref\n        agentHandlerRef.current = null\n      },\n      onError: async (error) => {\n        // 获取当前消息状态，保留 ragSources 和 ragSourceDetails\n        const currentState = useChatStore.getState()\n        const currentMessage = currentState.chats.find(c => c.id === placeholderMessage.id)\n\n        // 更新占位消息为错误信息，保留 RAG 相关字段\n        await saveChat({\n          id: placeholderMessage.id,\n          tagId: placeholderMessage.tagId,\n          conversationId: placeholderMessage.conversationId,\n          role: placeholderMessage.role,\n          type: placeholderMessage.type,\n          inserted: placeholderMessage.inserted,\n          createdAt: placeholderMessage.createdAt,\n          // 保留来自 currentMessage 的 RAG 相关字段\n          ragSources: currentMessage?.ragSources,\n          ragSourceDetails: currentMessage?.ragSourceDetails,\n          content: `Error: ${error}`,\n        }, true)\n\n        // 清空 Final Answer 模式状态\n        setAgentState({\n          activeChatId: undefined,\n          isFinalAnswerMode: false,\n          finalAnswerContent: undefined\n        })\n\n        // 清空 ref\n        agentHandlerRef.current = null\n      },\n    })\n\n    // 保存到 ref\n    agentHandlerRef.current = agentHandler\n\n    try {\n      // 构建上下文信息\n      let context = ''\n      let ragSources: string[] = []\n      let ragSourceDetails: RagSource[] = []\n\n      // 1. 如果有当前打开的笔记，自动传入其内容\n      const useArticleStore = (await import('@/stores/article')).default\n      const articleStore = useArticleStore.getState()\n\n      if (articleStore.activeFilePath && articleStore.currentArticle) {\n        context = `## 当前打开的笔记\\n文件路径: ${articleStore.activeFilePath}\\n\\n内容:\\n${articleStore.currentArticle}\\n\\n`\n      }\n\n      // 2. 如果启用 RAG，获取知识库相关上下文\n      if (isRagEnabled) {\n        try {\n          // 基于 TextRank 算法提取前 15 个关键词（增加数量以提高召回率）\n          let keywords = await invoke<{text: string, weight: number}[]>('rank_keywords', { text: inputValue, topK: 15 })\n\n          // 过滤掉停用词（如\"是\"、\"的\"等没有检索意义的虚词）\n          keywords = filterRAGKeywords(keywords)\n\n          // 如果过滤后没有有效关键词，明确告知\n          if (keywords.length === 0) {\n            context += `## 知识库检索结果\\n\\n由于用户问题中没有有效的关键词（仅包含停用词如\"的\"、\"是\"等），无法进行知识库检索。如果用户询问的是具体笔记内容，请告知用户需要提供更多具体信息。\\n`\n          } else {\n            // 根据关联资源类型选择检索方式\n            let ragResult: { context: string; sources: string[]; sourceDetails: RagSource[] }\n\n            if (linkedResource && isLinkedFolder(linkedResource)) {\n              // 文件夹关联：限定检索范围到文件夹\n              ragResult = await getContextForQueryInFolder(keywords, linkedResource.relativePath)\n            } else {\n              // 文件关联或无关联：全局检索\n              ragResult = await getContextForQuery(keywords)\n            }\n\n            ragSources = ragResult.sources\n            ragSourceDetails = ragResult.sourceDetails\n\n            // 设置到 agentState，用于实时显示\n            setAgentState({\n              ragSources,\n              ragSourceDetails,\n            })\n\n            if (ragResult.context) {\n              // 找到相关内容\n              context += `## 知识库检索结果\\n\\n已在知识库中找到与用户问题相关的笔记内容。请优先使用以下信息回答用户问题：\\n\\n${ragResult.context}\\n`\n            } else {\n              // 未找到相关内容\n              const searchScope = linkedResource && isLinkedFolder(linkedResource)\n                ? `在关联文件夹\"${linkedResource.name}\"中`\n                : '在知识库中'\n\n              context += `## 知识库检索结果\\n\\n${searchScope}未找到与用户问题相关的笔记内容。\\n\\n请根据情况处理：\\n- 如果用户询问的是具体笔记内容，请告知用户${searchScope}可能没有相关资料\\n- 如果问题可以基于一般知识回答，请使用你的知识回答\\n- 如果需要更多信息，可以请用户提供更具体的关键词或问题\\n`\n            }\n          }\n        } catch (error) {\n          console.error('Failed to get RAG context in Agent mode:', error)\n          // 检索出错时的处理\n          context += `## 知识库检索结果\\n\\n知识库检索过程中出现错误。如果用户询问的是具体笔记内容，请告知用户暂时无法访问知识库。\\n`\n        }\n      }\n\n      // 保存 RAG 来源到消息中（在 Agent 执行前保存，这样引用文件会在最上方显示）\n      if (ragSources.length > 0) {\n        await saveChat({\n          ...placeholderMessage,\n          ragSources: JSON.stringify(ragSources),\n          ragSourceDetails: ragSourceDetails.length > 0 ? JSON.stringify(ragSourceDetails) : undefined,\n        }, true)\n      }\n\n      // 3. 如果有关联文件（非文件夹），始终注入完整内容作为 Agent 上下文\n      if (linkedResource && !isLinkedFolder(linkedResource)) {\n        try {\n          const workspace = await getWorkspacePath()\n          let linkedFileContent = ''\n          if (workspace.isCustom) {\n            linkedFileContent = await readTextFile(linkedResource.path)\n          } else {\n            const { path, baseDir } = await getFilePathOptions(linkedResource.path)\n            linkedFileContent = await readTextFile(path, { baseDir })\n          }\n\n          if (linkedResourcePreview) {\n            context += `\\n${linkedResourcePreview}\\n`\n          }\n\n          if (linkedFileContent) {\n            context += `\\n## 关联文件完整内容\\n\\nThe full content of the linked file \"${linkedResource.name}\" (${linkedResource.relativePath}) is already included below. Do not call tools to read or check this same file again unless the user explicitly asks to refresh it.\\n\\n---\\n${linkedFileContent}\\n---\\n`\n          }\n        } catch (error) {\n          console.error('Failed to read linked file in Agent mode:', error)\n        }\n      }\n\n      // 4. 如果有引用内容，添加引用上下文（在构建消息之前）\n      if (quoteData) {\n        const { fileName, startLine, endLine, fullContent, from, to } = quoteData\n        let lineInfo = ''\n        const hasValidLineNumbers = startLine !== -1 && endLine !== -1\n        const hasValidRange = from >= 0 && to >= from\n\n        if (hasValidLineNumbers) {\n          if (startLine === endLine) {\n            lineInfo = `第 ${startLine} 行`\n          } else {\n            lineInfo = `第 ${startLine}-${endLine} 行`\n          }\n        }\n\n        context += `\\n## 📌 用户引用内容\n\n用户引用了笔记 \"${fileName}\" ${lineInfo}的以下内容：\n\n---\n${fullContent}\n---\n\n${hasValidRange ? `**仅在用户明确要求修改/改写/补充/插入时才允许编辑**。\n\n如果用户是在提问、解释、总结、分析、翻译、润色建议、代码说明，应该直接基于这段引用内容回答，**不要调用任何编辑工具**。\n\n**🚨 当且仅当用户明确要求修改时，必须精确替换用户选中的范围**: 当前引用内容来自编辑器选区，必须优先使用 replace_editor_content 的 position-based 模式，只替换这段选中的内容：\n- from: ${from}\n- to: ${to}\n- 使用 content 或 replaceContent 传入新内容\n- 只允许替换这个选区，禁止扩大到整篇文档或整段之外\n\n**如果用户说“在这段前面/后面/上面/下面插入、补充、添加”**:\n- 仍然使用 replace_editor_content\n- 基于当前引用范围整体替换\n- 前插: 新内容 + 原引用内容\n- 后插: 原引用内容 + 新内容\n- 不要使用 insert_at_cursor，因为聊天输入会让编辑器失焦，当前光标位置不可靠\n\n**如果用户明确要求“前面和后面都增加内容”**:\n- 仍然使用 replace_editor_content\n- 必须先分别生成前插内容和后插内容\n- 请在传给工具的 content 中使用这个精确格式：\n  <<BEFORE>>\n  [前插内容]\n  <<AFTER>>\n  [后插内容]\n- 系统会自动把它拼接成：前插内容 + 原引用内容 + 后插内容\n- 不要把前后内容合并成一整段普通文本\n\n**兜底行号信息**:\n- 单行修改: startLine: ${startLine}, endLine: ${endLine}\n- 多行范围: startLine: ${startLine}, endLine: ${endLine}\n\n**禁止**:\n- 禁止在解释/分析类请求中调用编辑工具\n- 禁止改动选区之外的内容\n- 禁止获取整个文档后再重写整篇\n- 禁止把 startLine/endLine 擅自改成 1/1` : hasValidLineNumbers ? `**🚨 必须使用行号修改**: 当用户引用内容并要求修改时，你必须使用 replace_editor_content 工具的 line-based 模式，传入精确的行号：\n` : hasValidLineNumbers ? `**仅在用户明确要求修改/改写/补充/插入时才允许编辑**。\n\n如果用户是在提问、解释、总结、分析、翻译、润色建议、代码说明，应该直接基于这段引用内容回答，**不要调用任何编辑工具**。\n\n**🚨 当且仅当用户明确要求修改时，必须使用行号修改**: 当用户引用内容并要求修改时，你必须使用 replace_editor_content 工具的 line-based 模式，传入精确的行号：\n- 单行修改: startLine: ${startLine}, endLine: ${endLine}\n- 多行范围: startLine: ${startLine}, endLine: ${endLine}\n- 必须使用 replaceContent 参数传入新内容\n\n**禁止**:\n- 禁止在解释/分析类请求中调用编辑工具\n- 禁止使用 from/to 位置参数\n- 禁止使用 searchContent 文本搜索模式\n- 禁止获取整个文档内容后再操作` : `**注意**: 此引用内容没有有效的行号信息。如果需要修改，请先使用 get_editor_selection 工具获取当前选中的行号信息。`}\n\n请基于这段引用内容回答用户的问题。\n\n`\n      }\n\n      // 5. 构建消息数组，包含对话历史（使用压缩摘要替代已压缩的消息）\n      const { chats } = useChatStore.getState()\n      const { buildMessagesWithHistory } = await import('@/lib/ai/condense')\n\n      // 使用 buildMessagesWithHistory 构建完整的消息数组\n      // 注意：Agent 模式下，不传入 systemPrompt（Agent 会自己构建）\n      // 将所有上下文（文章、RAG、关联文件、引用）作为 additionalContext\n      const messages = buildMessagesWithHistory(\n        chats,\n        undefined, // systemPrompt - Agent 会自己构建\n        context,   // additionalContext - 包含文章、RAG、关联文件、引用等\n        inputValue, // currentUserInput - 当前用户输入\n        {\n          // Agent 自己会在 think() 里重新注入当前请求，避免重复。\n          // 保留 assistant 历史，优先使用 condensedContent，避免丢失多轮上下文。\n          includeAssistantMessages: true,\n          includeLatestUserMessage: false,\n          maxUserMessages: shouldCarryUserHistoryForAgent(inputValue) ? 3 : 0,\n        }\n      )\n\n      await agentHandler.execute(inputValue, messages, imageUrls)\n    } catch (error) {\n      console.error('Agent execution error:', error)\n    } finally {\n      // 清空 ref\n      agentHandlerRef.current = null\n    }\n  }\n\n  // 对话（Agent 模式）\n  async function handleSubmit() {\n    if (inputValue === '') return\n    onSent?.()\n\n    setLoading(true)\n    const imageUrls = attachedImages.map(img => img.url)\n    await insert({\n      tagId: currentTagId,\n      role: 'user',\n      content: inputValue,\n      type: 'chat',\n      inserted: false,\n      images: imageUrls.length > 0 ? JSON.stringify(imageUrls) : undefined,\n      quoteData: quoteData ? JSON.stringify(quoteData) : undefined,\n    })\n    await handleAgentMode(imageUrls)\n    setLoading(false)\n  }\n\n  const handleStop = async () => {\n    // 停止普通对话的流式输出\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n      abortControllerRef.current = null\n    }\n\n    // 停止 Agent 执行\n    if (agentHandlerRef.current) {\n      agentHandlerRef.current.stop()\n      // 不立即清空 ref，等待 Agent 的错误处理完成并调用 onComplete\n    }\n\n    // 重置 loading 状态\n    setLoading(false)\n  }\n\n  return (\n    <>\n      <TooltipButton \n        variant={loading ? \"destructive\" : \"default\"}\n        size=\"sm\"\n        icon={loading ? <Square className=\"size-4\" /> : <Send className=\"size-4\" />} \n        disabled={!loading && (!primaryModel || !inputValue.trim())} \n        tooltipText={loading ? t('record.chat.input.stop') : t('record.chat.input.send')} \n        onClick={loading ? handleStop : handleSubmit} \n      />\n    </>\n  )\n})\n\nChatSend.displayName = 'ChatSend';\n"
  },
  {
    "path": "src/app/core/main/chat/chat-thinking.tsx",
    "content": "import { Chat } from \"@/db/chats\";\nimport { useState, useEffect, useRef } from \"react\";\nimport { Brain, ChevronRight, Loader2 } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\n\nexport default function ChatThinking({chat}: { chat: Chat }) {\n  const t = useTranslations()\n  const thinkingContent = chat.thinking || ''\n  const isThinking = !chat.content && !!chat.thinking // 还在思考中（有 thinking 但没有 content）\n  \n  const [isExpanded, setIsExpanded] = useState(isThinking)\n  const contentRef = useRef<HTMLDivElement>(null)\n  \n  // 当思考状态改变时，自动展开或折叠\n  useEffect(() => {\n    if (isThinking) {\n      setIsExpanded(true)\n    } else {\n      setIsExpanded(false)\n    }\n  }, [isThinking])\n  \n  // 思考内容更新时，自动滚动到底部\n  useEffect(() => {\n    if (isThinking && isExpanded && contentRef.current) {\n      contentRef.current.scrollTop = contentRef.current.scrollHeight\n    }\n  }, [thinkingContent, isThinking, isExpanded])\n  \n  if (!chat.thinking) {\n    return null\n  }\n\n  // 提取标题（第一行或前50个字符）\n  const extractTitle = (text: string): string => {\n    const firstLine = text.split('\\n')[0]\n    if (firstLine.length > 50) {\n      return firstLine.substring(0, 50) + '...'\n    }\n    return firstLine || text.substring(0, 50) + '...'\n  }\n  \n  const title = extractTitle(thinkingContent)\n  \n  return (\n    <div className=\"w-full space-y-1 mb-2 bg-muted/30 border border-border/50 rounded-lg overflow-hidden\">\n      {/* 思考卡片 - 单行 */}\n      <div\n        className={`flex items-center gap-2 py-1.5 px-3 cursor-pointer min-w-0 transition-colors ${isThinking ? 'bg-muted/50' : 'hover:bg-muted/40'}`}\n        onClick={() => setIsExpanded(!isExpanded)}\n      >\n        {isThinking ? (\n          <Loader2 className=\"size-4 animate-spin text-blue-500 flex-shrink-0\" />\n        ) : (\n          <Brain className=\"size-4 text-blue-500 flex-shrink-0\" />\n        )}\n        <span className=\"text-sm text-muted-foreground flex-1 truncate min-w-0\">\n          {isThinking ? t('ai.thinking') : title}\n        </span>\n        <ChevronRight className={`size-4 text-muted-foreground flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />\n      </div>\n\n      {/* 展开的详细内容 */}\n      {isExpanded && (\n        <div\n          ref={contentRef}\n          className=\"pl-6 pr-3 pb-2 text-xs text-muted-foreground whitespace-pre-wrap max-h-[250px] overflow-y-auto break-words bg-muted/20\"\n        >\n          {thinkingContent}\n        </div>\n      )}\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/core/main/chat/chat.css",
    "content": "@keyframes gradient {\n  0% {\n    background-position: 0% 50%;\n  }\n  50% {\n    background-position: 100% 50%;\n  }\n  100% {\n    background-position: 0% 50%;\n  }\n}\n\n.animate-gradient {\n  background-size: 200% 200%;\n  animation: gradient 2s ease infinite;\n}\n\n/* 自定义Markdown预览样式 */\n\n/* 基础样式 */\n.markdown-body {\n  box-sizing: border-box;\n  width: 100%;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\n  font-size: 16px;\n  line-height: 1.6;\n  background-color: hsl(var(--background)) !important;\n  color: hsl(var(--foreground)) !important;\n  margin-bottom: 0.5rem;\n}\n\n/* 暗黑模式样式 */\n.markdown-dark {\n  background-color: hsl(var(--muted));\n  color: hsl(var(--foreground)) !important;\n}\n\n.markdown-dark pre {\n  background-color: hsl(var(--muted));\n  color: hsl(var(--foreground)) !important;\n}\n\n.markdown-dark code {\n  background-color: hsl(var(--muted)) !important;\n  color: hsl(var(--foreground)) !important;\n  overflow-x: auto;\n}\n\n.markdown-dark a {\n  color: hsl(var(--primary)) !important;\n}\n\n.markdown-dark h1,\n.markdown-dark h2,\n.markdown-dark h3,\n.markdown-dark h4,\n.markdown-dark h5,\n.markdown-dark h6 {\n  color: hsl(var(--foreground)) !important;\n  border-bottom-color: hsl(var(--border)) !important;\n}\n\n.markdown-dark hr {\n  background-color: hsl(var(--border)) !important;\n}\n\n.markdown-dark blockquote {\n  border-left-color: hsl(var(--border)) !important;\n  color: hsl(var(--muted-foreground)) !important;\n}\n\n.markdown-dark table tr {\n  background-color: hsl(var(--background)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n.markdown-dark table tr:nth-child(2n) {\n  background-color: hsl(var(--muted) / 0.5) !important;\n}\n\n.markdown-dark table th,\n.markdown-dark table td {\n  border-color: hsl(var(--border)) !important;\n}\n\n/* 代码高亮容器 */\npre.hljs {\n  padding: 16px;\n  border-radius: 6px;\n  overflow-x: auto;\n  white-space: pre;\n  word-wrap: normal;\n  max-width: 100%;\n  overflow-x: auto;\n}\n\n/* 亮色模式下的代码高亮 */\n.hljs-light {\n  background: hsl(var(--muted));\n  color: hsl(var(--foreground));\n}\n\n/* 暗色模式下的代码高亮 */\n.hljs-dark {\n  background: hsl(var(--muted) / 0.5) !important;\n  color: hsl(var(--foreground)) !important;\n}\n\n/* 针对暗色模式下的代码高亮覆盖样式 */\n.hljs-dark .hljs-comment,\n.hljs-dark .hljs-punctuation {\n  color: hsl(var(--muted-foreground)) !important;\n}\n\n.hljs-dark .hljs-attr,\n.hljs-dark .hljs-attribute,\n.hljs-dark .hljs-meta,\n.hljs-dark .hljs-selector-attr,\n.hljs-dark .hljs-selector-class,\n.hljs-dark .hljs-selector-id {\n  color: hsl(var(--primary)) !important;\n}\n\n.hljs-dark .hljs-variable,\n.hljs-dark .hljs-literal,\n.hljs-dark .hljs-number,\n.hljs-dark .hljs-doctag {\n  color: hsl(var(--destructive)) !important;\n}\n\n.hljs-dark .hljs-string,\n.hljs-dark .hljs-regexp {\n  color: hsl(var(--primary)) !important;\n  opacity: 0.8;\n}\n\n.hljs-dark .hljs-built_in,\n.hljs-dark .hljs-keyword,\n.hljs-dark .hljs-name,\n.hljs-dark .hljs-selector-tag,\n.hljs-dark .hljs-tag {\n  color: hsl(var(--secondary)) !important;\n}\n\n/* 确保图片不会超过容器宽度 */\n.markdown-body img {\n  max-width: 100%;\n}\n\n/* 覆盖 Markdown 表格背景色 - 使用主题变量 */\n.markdown-body table tr {\n  background-color: hsl(var(--background)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: hsl(var(--muted) / 0.5) !important;\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  border-color: hsl(var(--border)) !important;\n}\n\n.markdown-body table th {\n  background-color: hsl(var(--muted) / 0.3) !important;\n}\n\n/* 覆盖代码块背景色 - 使用主题变量 */\n.markdown-body pre,\n.markdown-body .highlight pre,\n.markdown-body .highlight {\n  background-color: hsl(var(--muted) / 0.5) !important;\n  color: hsl(var(--foreground)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n/* 覆盖行内代码背景色 */\n.markdown-body code,\n.markdown-body tt {\n  background-color: hsl(var(--muted) / 0.5) !important;\n  color: hsl(var(--foreground)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n/* 覆盖 pre 内部的 code 背景色 */\n.markdown-body pre code,\n.markdown-body pre tt {\n  background-color: transparent !important;\n}\n\n/* 覆盖 CSV 数据表格背景色 */\n.markdown-body .csv-data .blob-num {\n  background: hsl(var(--background)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n.markdown-body .csv-data th {\n  background: hsl(var(--muted) / 0.5) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n/* 覆盖 kbd 元素样式 */\n.markdown-body kbd {\n  background-color: hsl(var(--muted)) !important;\n  color: hsl(var(--foreground)) !important;\n  border-color: hsl(var(--border)) !important;\n  box-shadow: none !important;\n}\n\n/* 覆盖 hr 分隔线颜色 */\n.markdown-body hr {\n  background-color: hsl(var(--border)) !important;\n  border-color: hsl(var(--border)) !important;\n}\n\n/* 覆盖 blockquote 样式 */\n.markdown-body blockquote {\n  border-left-color: hsl(var(--border)) !important;\n  color: hsl(var(--muted-foreground)) !important;\n}\n"
  },
  {
    "path": "src/app/core/main/chat/clear-chat.tsx",
    "content": "\"use client\"\nimport * as React from \"react\"\nimport { Eraser } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport useChatStore from \"@/stores/chat\"\nimport useTagStore from \"@/stores/tag\"\nimport { useTranslations } from 'next-intl'\n\nexport function ClearChat() {\n  const { clearChats } = useChatStore()\n  const { currentTagId } = useTagStore()\n  const t = useTranslations()\n\n  function clearHandler() {\n    clearChats(currentTagId)\n  }\n\n  return (\n    <div>\n      <TooltipButton icon={<Eraser />} tooltipText={t('record.chat.input.clearChat')} side=\"bottom\" onClick={clearHandler}/>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/clear-context.tsx",
    "content": "\"use client\"\n\nimport React from \"react\"\nimport { AlignVerticalJustifyCenter } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport useChatStore from \"@/stores/chat\"\nimport useTagStore from \"@/stores/tag\"\nimport { useTranslations } from 'next-intl'\n\nexport function ClearContext() {\n  const { insert } = useChatStore()\n  const { currentTagId } = useTagStore()\n  const t = useTranslations('record.chat.input.clearContext')\n\n  const handleClearContext = async () => {\n    // 插入一条系统消息，表示清除上下文\n    await insert({\n      tagId: currentTagId,\n      role: 'system',\n      content: '上下文已清除，之后的对话将只携带此消息之后的内容。',\n      type: 'clear',\n      inserted: true,\n      image: undefined,\n    })\n  }\n\n  return (\n    <div>\n      <TooltipButton\n        variant=\"ghost\"\n        size=\"icon\"\n        icon={<AlignVerticalJustifyCenter className=\"size-4\" />}\n        tooltipText={t('tooltip')}\n        side=\"bottom\"\n        onClick={handleClearContext}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/clipboard-listener.tsx",
    "content": "'use client'\nimport { clear, hasImage, hasText, readImageBase64, readText } from \"tauri-plugin-clipboard-api\";\nimport { useEffect, useRef } from 'react';\nimport { BaseDirectory, exists, mkdir, writeFile } from '@tauri-apps/plugin-fs';\nimport { listen, UnlistenFn } from \"@tauri-apps/api/event\";\nimport { v4 as uuid } from \"uuid\";\nimport useChatStore from \"@/stores/chat\";\nimport useTagStore from \"@/stores/tag\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\nexport function ClipboardListener() {\n  const { insert, chats, loading } = useChatStore()\n  const chatsRef = useRef(chats)\n  const { currentTagId } = useTagStore()\n\n  async function readHandler() {\n    const store = await Store.load('store.json')\n    const isEnabled = await store.get<boolean>('clipboardMonitor')\n    if (!isEnabled) return\n    if (loading) return\n    const hasImageRes = await hasImage()\n    const hasTextRes = await hasText()\n\n    if (hasImageRes) {\n      await handleImage()\n    } else if (hasTextRes) {\n      await handleText()\n    }\n  }\n\n  async function handleImage() {\n    const isClipboardFolderExists = await exists('clipboard', { baseDir: BaseDirectory.AppData})\n    if (!isClipboardFolderExists) {\n      await mkdir('clipboard', { baseDir: BaseDirectory.AppData })\n    }\n    const image = await readImageBase64()\n    const uint8Array = Uint8Array.from(atob(image), c => c.charCodeAt(0)) || new Uint8Array()\n    const path = `clipboard/${uuid()}.png`\n    await writeFile(path, uint8Array, { baseDir: BaseDirectory.AppData })\n    await clear()\n    await insert({\n      role: 'system',\n      content: '',\n      type: 'clipboard',\n      image: `/${path}`,\n      tagId: currentTagId,\n      inserted: false\n    })\n  }\n\n  async function handleText() {\n    const text = await readText()\n    const chatsContent = chatsRef.current.map(item => item.content)\n    if (!chatsContent.includes(text)) {\n      await insert({\n        role: 'system',\n        content: text,\n        type: 'clipboard',\n        tagId: currentTagId,\n        inserted: false\n      })\n    }\n  }\n\n  useEffect(() => {\n    chatsRef.current = chats;\n  }, [chats]); \n\n  useEffect(() => {\n    let unlisten: UnlistenFn | undefined;\n    \n    async function initListen() {\n      unlisten = await listen('tauri://focus', readHandler)\n    }\n    initListen()\n\n    return () => {\n      if (unlisten) {\n        unlisten()\n      }\n    }\n  }, [])\n\n  return <></>\n}"
  },
  {
    "path": "src/app/core/main/chat/clipboard-monitor.tsx",
    "content": "\"use client\"\nimport { useTranslations } from 'next-intl'\nimport { Clipboard, ClipboardX } from 'lucide-react'\nimport { TooltipButton } from '@/components/tooltip-button'\nimport { useState, useEffect } from 'react'\nimport { Store } from '@tauri-apps/plugin-store'\n\nexport function ClipboardMonitor() {\n  const t = useTranslations('record.chat.input.clipboardMonitor')\n  const [isEnabled, setIsEnabled] = useState(true)\n\n  // Sync with store.json on mount\n  useEffect(() => {\n    const syncWithStore = async () => {\n      try {\n        const store = await Store.load('store.json')\n        const storedValue = await store.get<boolean>('clipboardMonitor')\n\n        // Only update if the stored value exists and is different from the current state\n        if (storedValue !== undefined && storedValue !== isEnabled) {\n          setIsEnabled(storedValue)\n        }\n      } catch (error) {\n        console.error('Failed to load clipboard monitor state from store:', error)\n      }\n    }\n\n    syncWithStore()\n  }, [])\n\n  const toggleClipboardMonitor = async () => {\n    const newState = !isEnabled\n    setIsEnabled(newState)\n    const store = await Store.load('store.json')\n    await store.set('clipboardMonitor', newState)\n  }\n\n  return (\n    <div>\n      <TooltipButton\n        variant={\"ghost\"}\n        size=\"icon\"\n        icon={isEnabled ? <Clipboard className=\"size-4\" /> : <ClipboardX className=\"size-4\" />}\n        tooltipText={isEnabled ? t('enable') : t('disable')}\n        side=\"bottom\"\n        onClick={toggleClipboardMonitor}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/file-link.tsx",
    "content": "\"use client\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { AtSign, X, FolderOpen } from \"lucide-react\"\nimport { LinkedResource, isLinkedFolder } from \"@/lib/files\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { useTranslations } from 'next-intl'\n\ninterface FileLinkProps {\n  onFileLinkClick: () => void\n  disabled?: boolean\n}\n\nexport function FileLink({ onFileLinkClick, disabled = false }: FileLinkProps) {\n  const t = useTranslations('record.chat.input.fileLink')\n\n  return (\n    <div>\n      <TooltipButton\n        icon={<AtSign className=\"size-4\" />}\n        tooltipText={t('tooltip')}\n        size=\"icon\"\n        side=\"bottom\"\n        onClick={onFileLinkClick}\n        disabled={disabled}\n      />\n    </div>\n  )\n}\n\n// 独立的关联资源显示组件\ninterface LinkedResourceDisplayProps {\n  linkedResource: LinkedResource | null\n  onFileRemove: () => void\n}\n\nexport function LinkedFileDisplay({ linkedResource, onFileRemove }: LinkedResourceDisplayProps) {\n  if (!linkedResource) return null\n\n  const isFolder = isLinkedFolder(linkedResource)\n\n  return (\n    <div className=\"flex items-center justify-between bg-third rounded-xl rounded-b-none px-2 text-sm border-t border-l border-r w-full pb-2 translate-y-2\">\n      <div className=\"flex items-center gap-2 opacity-50\">\n        {isFolder ? (\n          <FolderOpen className=\"size-3\" />\n        ) : (\n          <AtSign className=\"size-3\" />\n        )}\n        <span className=\"font-medium text-xs\">{linkedResource.name}</span>\n        {isFolder && (\n          <span className=\"text-xs opacity-70\">\n            ({(linkedResource as any).indexedCount}/{(linkedResource as any).fileCount})\n          </span>\n        )}\n      </div>\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onFileRemove}\n        className=\"size-6 p-0 opacity-50\"\n      >\n        <X className=\"size-3\" />\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/file-selector.tsx",
    "content": "\"use client\"\n\nimport { useState, useEffect, useRef } from \"react\"\nimport { Input } from \"@/components/ui/input\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport { FileText } from \"lucide-react\"\nimport { getAllMarkdownFiles, MarkdownFile } from \"@/lib/files\"\nimport { cn } from \"@/lib/utils\"\nimport { useTranslations } from 'next-intl'\n\ninterface FileSelectorProps {\n  onFileSelect: (file: MarkdownFile) => void\n  onClose: () => void\n  isOpen: boolean\n}\n\nexport function FileSelector({ onFileSelect, onClose, isOpen }: FileSelectorProps) {\n  const [files, setFiles] = useState<MarkdownFile[]>([])\n  const [filteredFiles, setFilteredFiles] = useState<MarkdownFile[]>([])\n  const [searchQuery, setSearchQuery] = useState(\"\")\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const [loading, setLoading] = useState(false)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const containerRef = useRef<HTMLDivElement>(null)\n  const t = useTranslations('record.chat.input.fileLink')\n\n  // 加载所有Markdown文件\n  useEffect(() => {\n    if (isOpen) {\n      loadFiles()\n      // 自动聚焦搜索框\n      setTimeout(() => {\n        inputRef.current?.focus()\n      }, 100)\n    }\n  }, [isOpen])\n\n  // 过滤文件\n  useEffect(() => {\n    if (!searchQuery.trim()) {\n      setFilteredFiles(files)\n    } else {\n      const query = searchQuery.toLowerCase()\n      const filtered = files.filter(file => \n        file.name.toLowerCase().includes(query) ||\n        file.relativePath.toLowerCase().includes(query)\n      )\n      setFilteredFiles(filtered)\n    }\n    setSelectedIndex(0)\n  }, [searchQuery, files])\n\n  const loadFiles = async () => {\n    setLoading(true)\n    try {\n      const allFiles = await getAllMarkdownFiles()\n      setFiles(allFiles)\n      setFilteredFiles(allFiles)\n    } catch (error) {\n      console.error('加载文件失败:', error)\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      setSelectedIndex(prev => Math.min(prev + 1, filteredFiles.length - 1))\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      setSelectedIndex(prev => Math.max(prev - 1, 0))\n    } else if (e.key === 'Enter') {\n      e.preventDefault()\n      if (filteredFiles[selectedIndex]) {\n        handleFileSelect(filteredFiles[selectedIndex])\n      }\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      onClose()\n    }\n  }\n\n  const handleFileSelect = (file: MarkdownFile) => {\n    onFileSelect(file)\n    onClose()\n    setSearchQuery(\"\")\n  }\n\n  // 点击外部关闭\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {\n        onClose()\n      }\n    }\n\n    if (isOpen) {\n      document.addEventListener('mousedown', handleClickOutside)\n      return () => document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [isOpen, onClose])\n\n  if (!isOpen) return null\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/20\">\n      <div \n        ref={containerRef}\n        className=\"bg-background border rounded-lg shadow-lg w-full max-w-md mx-4 max-h-[80vh] flex flex-col\"\n      >\n        {/* 搜索框 */}\n        <div className=\"p-4 border-b\">\n          <Input\n            ref={inputRef}\n            type=\"text\"\n            placeholder={t('searchPlaceholder')}\n            value={searchQuery}\n            onChange={(e) => setSearchQuery(e.target.value)}\n            className=\"w-full\"\n            onKeyDown={handleKeyDown}\n          />\n        </div>\n\n        {/* 文件列表 */}\n        <ScrollArea className=\"flex-1 max-h-[400px]\">\n          {loading ? (\n            <div className=\"flex items-center justify-center py-8\">\n              <div className=\"text-sm text-muted-foreground\">{t('loading')}</div>\n            </div>\n          ) : filteredFiles.length === 0 ? (\n            <div className=\"flex items-center justify-center py-8\">\n              <div className=\"text-sm text-muted-foreground\">{t('noFiles')}</div>\n            </div>\n          ) : (\n            <div className=\"p-2\">\n              {filteredFiles.map((file, index) => (\n                <div\n                  key={file.path}\n                  className={cn(\n                    \"flex items-center gap-3 p-2 rounded-md cursor-pointer transition-colors\",\n                    index === selectedIndex \n                      ? \"bg-accent text-accent-foreground\" \n                      : \"hover:bg-accent/50\"\n                  )}\n                  onClick={() => handleFileSelect(file)}\n                >\n                  <FileText className=\"size-4 text-muted-foreground flex-shrink-0\" />\n                  <div className=\"flex-1 min-w-0\">\n                    <div className=\"font-medium truncate\">{file.name}</div>\n                    <div className=\"text-xs text-muted-foreground truncate\">\n                      {file.relativePath}\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </ScrollArea>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/history-dropdown.tsx",
    "content": "'use client'\n\nimport { useState, useMemo } from 'react'\nimport { ChevronDown, Search, Trash2 } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport type { Conversation } from '@/db/conversations'\nimport { useTranslations } from 'next-intl'\n\ninterface HistoryDropdownProps {\n  conversations: Conversation[]\n  currentConversationId: number | null\n  excludeConversationIds?: number[]\n  onSwitch: (id: number) => void\n  onDelete: (id: number) => void\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function HistoryDropdown({\n  conversations,\n  currentConversationId,\n  excludeConversationIds = [],\n  onSwitch,\n  onDelete,\n  open,\n  onOpenChange\n}: HistoryDropdownProps) {\n  const t = useTranslations('record.chat.empty')\n  const [searchQuery, setSearchQuery] = useState('')\n\n  // 过滤并排序会话（排除当前会话、已显示会话和空会话）\n  const filteredConversations = useMemo(() => {\n    return conversations\n      .filter(c => c.id !== currentConversationId && !excludeConversationIds.includes(c.id) && c.messageCount > 0)\n      .filter(c => c.title.toLowerCase().includes(searchQuery.toLowerCase()))\n      .sort((a, b) => {\n        // 置顶的排在前面\n        if (a.isPinned && !b.isPinned) return -1\n        if (!a.isPinned && b.isPinned) return 1\n        // 然后按更新时间排序\n        return b.updatedAt - a.updatedAt\n      })\n  }, [conversations, currentConversationId, excludeConversationIds, searchQuery])\n\n  return (\n    <DropdownMenu open={open} onOpenChange={onOpenChange}>\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          className=\"px-1 hover:bg-transparent cursor-pointer justify-start\"\n        >\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-xs font-medium\">{t('viewMore')}</span>\n            <span className=\"text-xs text-muted-foreground\">\n              ({filteredConversations.length})\n            </span>\n          </div>\n          <ChevronDown className=\"w-4 h-4 text-muted-foreground\" />\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"end\"\n        className=\"w-[340px] max-h-[400px] overflow-y-auto\"\n      >\n        {/* 搜索框 */}\n        <div className=\"px-2 py-2\">\n          <div className=\"relative\">\n            <Search className=\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground\" />\n            <Input\n              placeholder={t('searchPlaceholder')}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              className=\"pl-8 h-8 text-sm\"\n            />\n          </div>\n        </div>\n\n        <DropdownMenuSeparator />\n\n        {/* 会话列表 */}\n        {filteredConversations.length === 0 ? (\n          <div className=\"px-4 py-8 text-center text-sm text-muted-foreground\">\n            {searchQuery ? t('noMatchingConversations') : t('noConversationHistory')}\n          </div>\n        ) : (\n          <div className=\"max-h-[300px] overflow-y-auto\">\n            {filteredConversations.map(conv => (\n              <DropdownMenuItem\n                key={conv.id}\n                className=\"cursor-pointer group\"\n              >\n                <div\n                  className=\"flex-1 min-w-0\"\n                  onClick={() => onSwitch(conv.id)}\n                >\n                  <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                      <span className=\"text-sm truncate group-hover:text-primary transition-colors\">\n                        {conv.title}\n                      </span>\n                    </div>\n                    <div className=\"shrink-0 ml-auto flex items-center gap-2\">\n                      {/* 删除按钮 - 悬停时显示 */}\n                      <button\n                        onClick={(e) => {\n                          e.stopPropagation()\n                          onDelete(conv.id)\n                        }}\n                        className=\"flex items-center justify-center rounded-md text-muted-foreground opacity-0 group-hover:opacity-100 transition-all duration-200 ease-out hover:text-destructive hover:bg-destructive/10 active:scale-95\"\n                        title={t('deleteConversation')}\n                      >\n                        <Trash2 className=\"w-3.5 h-3.5 transition-transform duration-150 group-hover/button:scale-110\" />\n                      </button>\n                    </div>\n                  </div>\n                </div>\n              </DropdownMenuItem>\n            ))}\n          </div>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/image-attachments.tsx",
    "content": "\"use client\"\nimport { X } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport Image from \"next/image\"\nimport { PhotoProvider, PhotoView } from \"react-photo-view\"\n\nexport interface ImageAttachment {\n  id: string\n  url: string\n  name?: string\n  source?: 'paste' | 'file' | 'record'\n}\n\ninterface ImageAttachmentsProps {\n  images: ImageAttachment[]\n  onRemove: (id: string) => void\n}\n\nexport function ImageAttachments({ images, onRemove }: ImageAttachmentsProps) {\n  if (images.length === 0) return null\n\n  return (\n    <PhotoProvider>\n      <div className=\"flex flex-wrap gap-2 p-1\">\n        {images.map((image) => (\n          <div\n            key={image.id}\n            className=\"relative group rounded-lg overflow-hidden border bg-muted cursor-pointer\"\n            style={{ width: '40px', height: '40px' }}\n          >\n            <PhotoView src={image.url}>\n              <Image\n                src={image.url}\n                alt={image.name || 'Attached image'}\n                fill\n                className=\"object-cover\"\n                unoptimized\n              />\n            </PhotoView>\n            <Button\n              variant=\"destructive\"\n              size=\"icon\"\n              className=\"absolute top-0 right-0 h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity\"\n              onClick={(e) => {\n                e.stopPropagation()\n                onRemove(image.id)\n              }}\n            >\n              <X className=\"h-2.5 w-2.5\" />\n            </Button>\n          </div>\n        ))}\n      </div>\n    </PhotoProvider>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/index.tsx",
    "content": "'use client'\nimport { ChatHeader } from './chat-header'\nimport { ChatFooter } from './chat-footer'\nimport { ChatInput } from \"./chat-input\";\nimport ChatContent from \"./chat-content\";\nimport { ClipboardListener } from \"./clipboard-listener\";\n\nexport default function Chat() {\n  return <div id=\"record-chat\" className=\"flex-col flex-1 flex relative overflow-x-hidden items-center h-full overflow-hidden\">\n    <ChatHeader />\n    <ChatContent />\n    <ClipboardListener />\n    <ChatInput />\n    <ChatFooter />\n  </div>\n}\n"
  },
  {
    "path": "src/app/core/main/chat/mcp-button.tsx",
    "content": "'use client'\n\nimport * as React from 'react'\nimport { useState } from 'react'\nimport { ServerCrash, Server, Plug, PlugZap } from 'lucide-react'\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover'\nimport {\n  Command,\n  CommandEmpty,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command'\nimport { TooltipButton } from '@/components/tooltip-button'\nimport { Badge } from '@/components/ui/badge'\nimport { Switch } from '@/components/ui/switch'\nimport { useMcpStore } from '@/stores/mcp'\nimport { useTranslations } from 'next-intl'\n\nexport function McpButton() {\n  const t = useTranslations('mcp')\n  const [open, setOpen] = useState(false)\n  const { servers, selectedServerIds, toggleServerSelection, initMcpData, serverStates } = useMcpStore()\n  \n  function handleSetOpen(isOpen: boolean) {\n    setOpen(isOpen)\n    if (isOpen) {\n      initMcpData()\n    }\n  }\n\n  const enabledServers = servers.filter(s => s.enabled)\n  \n  return (\n    <Popover open={open} onOpenChange={handleSetOpen}>\n      <PopoverTrigger asChild>\n        <div className=\"hidden md:block relative\">\n          <TooltipButton\n            icon={selectedServerIds.length ? <ServerCrash className=\"size-4\" /> : <Server className=\"size-4\" />}\n            tooltipText={t('selectServers')}\n            size=\"icon\"\n            side=\"bottom\"\n          />\n        </div>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[400px] p-0\">\n        <Command>\n          <CommandInput placeholder={t('searchServers')} className=\"h-9\" />\n          <CommandList>\n            <CommandEmpty>{t('noServersFound')}</CommandEmpty>\n            {enabledServers.map((server) => {\n              const state = serverStates.get(server.id)\n              const status = state?.status || 'disconnected'\n              const toolCount = state?.tools?.length || 0\n              \n              return (\n                <CommandItem\n                  key={server.id}\n                  value={server.name}\n                  onSelect={() => {\n                    toggleServerSelection(server.id)\n                  }}\n                >\n                  <div className=\"flex flex-col flex-1 gap-1 min-w-0\">\n                    <div className=\"flex items-center gap-2 min-w-0\">\n                      <span className=\"font-medium truncate\">{server.name}</span>\n                      <Badge variant=\"outline\" className=\"text-[10px] px-1 py-0 h-4\">\n                        {server.type}\n                      </Badge>\n                      {status === 'connected' ? (\n                        <div className=\"flex items-center gap-1\">\n                          <PlugZap className=\"size-3 text-green-500\" />\n                          <span className=\"text-[10px] text-green-600 dark:text-green-400\">\n                            {toolCount} {t('tools')}\n                          </span>\n                        </div>\n                      ) : status === 'connecting' ? (\n                        <div className=\"flex items-center gap-1\">\n                          <Plug className=\"size-3 text-yellow-500 animate-pulse\" />\n                          <span className=\"text-[10px] text-yellow-600 dark:text-yellow-400\">\n                            {t('connecting')}\n                          </span>\n                        </div>\n                      ) : (\n                        <div className=\"flex items-center gap-1\">\n                          <Plug className=\"size-3 text-muted-foreground\" />\n                          <span className=\"text-[10px] text-muted-foreground\">\n                            {t('disconnected')}\n                          </span>\n                        </div>\n                      )}\n                    </div>\n                    <span className=\"text-xs text-muted-foreground truncate\">\n                      {server.type === 'stdio' ? `${server.command} ${server.args?.join(' ') || ''}` : `${server.url}`}\n                    </span>\n                  </div>\n                  <div\n                    className=\"ml-2 shrink-0\"\n                    onClick={(event) => event.stopPropagation()}\n                    onPointerDown={(event) => event.stopPropagation()}\n                  >\n                    <Switch\n                      checked={selectedServerIds.includes(server.id)}\n                      aria-label={`${t('selectServers')}: ${server.name}`}\n                      onCheckedChange={() => toggleServerSelection(server.id)}\n                    />\n                  </div>\n                </CommandItem>\n              )\n            })}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/mcp-tool-call.tsx",
    "content": "'use client'\n\nimport { McpToolCall } from '@/stores/chat'\nimport { Card } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { CheckCircle2, XCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react'\nimport { useState } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { useTranslations } from 'next-intl'\n\ninterface McpToolCallCardProps {\n  toolCall: McpToolCall\n}\n\nexport function McpToolCallCard({ toolCall }: McpToolCallCardProps) {\n  const [expanded, setExpanded] = useState(false)\n  const t = useTranslations('record.mark.mark.chat.mcp')\n\n  const getStatusIcon = () => {\n    switch (toolCall.status) {\n      case 'calling':\n        return <Loader2 className=\"size-4 animate-spin text-yellow-500\" />\n      case 'success':\n        return <CheckCircle2 className=\"size-4 text-green-500\" />\n      case 'error':\n        return <XCircle className=\"size-4 text-red-500\" />\n    }\n  }\n\n  return (\n    <Card className=\"p-3 bg-muted/30\">\n      <div className=\"space-y-2\">\n        {/* 头部 */}\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"font-medium text-sm\">{t('toolCall')}</span>\n            <Badge variant=\"outline\" className=\"text-xs\">\n              {toolCall.serverName}\n            </Badge>\n            <span className=\"font-medium text-xs text-muted-foreground\">{toolCall.toolName}</span>\n          </div>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => setExpanded(!expanded)}\n            className=\"h-6 w-6 p-0\"\n          >\n            {expanded ? <ChevronUp className=\"size-4\" /> : <ChevronDown className=\"size-4\" />}\n          </Button>\n        </div>\n\n        {/* 展开内容 */}\n        {expanded && (\n          <div className=\"space-y-3 pt-2\">\n            {/* 参数 */}\n            <div className=\"space-y-1\">\n              <div className=\"flex items-center justify-between\">\n                <span className=\"text-xs font-medium text-muted-foreground\">{t('params')}:</span>\n              </div>\n              <pre className=\"text-xs bg-background/50 p-2 rounded overflow-x-auto whitespace-pre-wrap break-words\">\n                <code className=\"text-green-600 dark:text-green-400\">\n                  {JSON.stringify(toolCall.params, null, 2)}\n                </code>\n              </pre>\n            </div>\n\n            {/* 结果 */}\n            {toolCall.result && (\n              <div className=\"space-y-1\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-xs font-medium text-muted-foreground\">{t('result')}:</span>\n                </div>\n                <pre className=\"text-xs bg-background/50 p-2 rounded overflow-x-auto max-h-60 overflow-y-auto whitespace-pre-wrap break-words\">\n                  <code className={toolCall.status === 'error' ? 'text-red-600 dark:text-red-400' : ''}>\n                    {toolCall.result}\n                  </code>\n                </pre>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/condensed-indicator.tsx",
    "content": "import { Chat } from \"@/db/chats\"\nimport { FileText } from \"lucide-react\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { useTranslations } from 'next-intl'\n\ninterface CondensedIndicatorProps {\n  chat: Chat\n}\n\nexport function CondensedIndicator({ chat }: CondensedIndicatorProps) {\n  const t = useTranslations('record.chat.messageControl')\n\n  // 仅在有 condensedContent 时显示\n  if (!chat.condensedContent) {\n    return null\n  }\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <div className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-help transition-colors\">\n          <FileText className=\"size-4\" />\n          <span>{t('summary')}</span>\n        </div>\n      </PopoverTrigger>\n      <PopoverContent side=\"top\" className=\"max-w-xs\">\n        <p className=\"text-xs whitespace-pre-wrap\">{chat.condensedContent}</p>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/copy-control.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Chat } from \"@/db/chats\"\nimport { Copy, Check } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport { writeText } from \"tauri-plugin-clipboard-api\"\n\ninterface CopyControlProps {\n  chat: Chat\n  translatedContent?: string\n}\n\nexport function CopyControl({ chat, translatedContent }: CopyControlProps) {\n  const t = useTranslations()\n  const [isCopied, setIsCopied] = useState(false)\n  \n  // 处理复制功能\n  async function handleCopy() {\n    if (!chat.content || isCopied) return\n    \n    try {\n      // 使用翻译后的内容或原始内容\n      let textToCopy = translatedContent || chat.content\n      \n      // 清理多余的空白字符\n      textToCopy = textToCopy.trim()\n      \n      if (!textToCopy) {\n        console.warn('复制内容为空')\n        return\n      }\n      \n      await writeText(textToCopy)\n      setIsCopied(true)\n      \n      // 2秒后重置复制状态\n      setTimeout(() => {\n        setIsCopied(false)\n      }, 2000)\n    } catch (error) {\n      console.error('复制失败:', error)\n    }\n  }\n\n  if (!chat.content || chat.type !== 'chat') {\n    return null\n  }\n\n  return (\n    <>\n      <TooltipButton\n        icon={\n          isCopied ? (\n            <Check className=\"h-4 w-4\" />\n          ) : (\n            <Copy className=\"h-4 w-4\" />\n          )\n        }\n        tooltipText={\n          isCopied ? t('record.chat.messageControl.copied') : \n          t('record.chat.messageControl.copy')\n        }\n        onClick={handleCopy}\n        variant=\"ghost\"\n        size=\"sm\"\n        disabled={isCopied}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/index.tsx",
    "content": "import { Chat } from \"@/db/chats\"\nimport useChatStore from \"@/stores/chat\"\nimport { XIcon } from \"lucide-react\"\nimport { clear, hasText, readText } from \"tauri-plugin-clipboard-api\"\nimport { useState } from \"react\"\nimport { MessageInfo } from \"./message-info\"\nimport { CondensedIndicator } from \"./condensed-indicator\"\nimport { TranslateControl } from \"./translate-control\"\nimport { CopyControl } from \"./copy-control\"\nimport { ReadAloudControl } from \"./read-aloud-control\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { useTranslations } from 'next-intl';\n\nexport default function MessageControl({chat, children}: {chat: Chat, children: React.ReactNode}) {\n  const { deleteChat } = useChatStore()\n  const [translatedContent, setTranslatedContent] = useState<string>('')\n  const t = useTranslations('common')\n  \n  async function deleteHandler() {\n    if (chat.type === \"clipboard\" && !chat.image) {\n      const hasTextRes = await hasText()\n      if (hasTextRes) {\n        try {\n          const text = await readText()\n          if (text === chat.content) {\n            await clear()\n          }\n        } catch {}\n      }\n    }\n    deleteChat(chat.id)\n  }\n\n  return (\n    <>\n      <div className='flex items-center justify-between mt-2'>\n\n        <div className=\"flex items-center gap-2\">\n          <MessageInfo chat={chat} />\n          <CondensedIndicator chat={chat} />\n        </div>\n\n        <div className='flex items-center'>\n          {children || null}\n\n          <CopyControl\n            chat={chat}\n            translatedContent={translatedContent}\n          />\n\n          <TranslateControl\n            chat={chat}\n            onTranslatedContent={setTranslatedContent}\n          />\n\n          <ReadAloudControl\n            chat={chat}\n            translatedContent={translatedContent}\n          />\n\n          <TooltipButton icon={<XIcon className='size-4' />} tooltipText={t('delete')} variant={\"ghost\"} size={\"icon\"} onClick={deleteHandler}/>\n        </div>\n      </div>\n\n      {/* 显示翻译结果 */}\n      {translatedContent && (\n        <div className=\"mt-2 pt-2 border-t border-border\">\n          <div className=\"whitespace-pre-wrap\">{translatedContent}</div>\n        </div>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/mark-text.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Chat } from \"@/db/chats\"\nimport { insertMark } from \"@/db/marks\"\nimport useChatStore from \"@/stores/chat\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { CheckCircle, Highlighter } from \"lucide-react\"\nimport {useEffect, useState} from \"react\";\nimport { useTranslations } from 'next-intl';\n\nexport function MarkText({chat}: {chat: Chat}) {\n\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks, marks } = useMarkStore()\n  const { updateInsert, chats } = useChatStore()\n  const [isRecorded, setIsRecorded] = useState(chat.inserted)\n  const t = useTranslations('record.queue')\n\n  useEffect(() => {\n    const currentIndex = chats.findIndex(item => item.id === chat.id)\n    const prevChat = chats[currentIndex - 1]\n\n    if (!prevChat || !chat.content) {\n       setIsRecorded(false)\n       return\n    }\n\n    const contentToCheck = `\n${prevChat?.content}\n${chat.content}\n`.replace(/'/g, '')\n\n    const markExists = marks.some(mark =>\n       mark.type === 'text' &&\n       mark.content === contentToCheck\n    )\n\n    setIsRecorded(markExists)\n  }, [marks, chat.id, chats])\n\n  async function handleSuccess() {\n    const currentIndex = chats.findIndex(item => item.id === chat.id)\n    const prevChat = chats[currentIndex - 1]\n    const res = `\n${prevChat?.content}\n${chat.content}\n`\n    const resetText = res.replace(/'/g, '')\n    await insertMark({ tagId: currentTagId, type: 'text', desc: resetText, content: resetText })\n    updateInsert(chat.id)\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n    setIsRecorded(true)\n  }\n\n  return (\n    isRecorded ?\n      <TooltipButton icon={<CheckCircle className=\"size-4\" />} tooltipText={t('recorded')} variant={\"ghost\"} size=\"sm\" disabled/> :\n      <TooltipButton icon={<Highlighter className=\"size-4\" />} tooltipText={t('record')} variant={\"ghost\"} size=\"sm\" onClick={handleSuccess}/>\n  )\n}"
  },
  {
    "path": "src/app/core/main/chat/message-control/message-info.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { Chat } from \"@/db/chats\"\nimport dayjs from \"dayjs\"\nimport { Clock } from \"lucide-react\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\n\ndayjs.extend(relativeTime)\n\ninterface MessageInfoProps {\n  chat: Chat\n}\n\nexport function MessageInfo({ chat }: MessageInfoProps) {\n\n  return (\n    <div className='flex items-center gap-1 -translate-x-3'>\n      <Button variant={\"ghost\"} size=\"sm\" disabled>\n        <Clock className=\"size-4 hidden md:inline\" />\n        {dayjs(chat.createdAt).fromNow()}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/note-output.tsx",
    "content": "'use client'\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { extractTitle } from \"@/lib/markdown\"\nimport { getFilePathOptions, getWorkspacePath, getGenericPathOptions } from \"@/lib/workspace\"\nimport useTagStore from \"@/stores/tag\"\nimport { CheckedState } from \"@radix-ui/react-checkbox\"\nimport { BaseDirectory, readDir, writeTextFile } from \"@tauri-apps/plugin-fs\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { SquarePen, TriangleAlert } from \"lucide-react\"\nimport { useEffect, useState } from \"react\"\nimport { redirect } from 'next/navigation'\nimport { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\"\nimport { Chat } from \"@/db/chats\"\nimport { useTranslations } from \"next-intl\"\nimport useArticleStore from \"@/stores/article\"\n\nexport function NoteOutput({chat}: {chat: Chat}) {\n  const { deleteTag, currentTagId } = useTagStore()\n  const { loadFileTree } = useArticleStore()\n  const [open, setOpen] = useState(false);\n  const [title, setTitle] = useState('')\n  const [path, setPath] = useState('/')\n  const [folders, setFolders] = useState<string[]>([])\n  const [isRemove, setIsRemove] = useState<CheckedState>(true)\n  const t = useTranslations('record.chat')\n\n  async function handleTransform() {\n    const content = chat?.content || ''\n    // 统一处理：将空格替换为下划线，确保本地和远程文件名一致\n    const sanitizedTitle = title.replace(/\\s+/g, '_')\n    const writePath = `${path}/${sanitizedTitle}`\n    \n    // Use workspace functions instead of directly using BaseDirectory.AppData\n    const pathOptions = await getFilePathOptions(writePath)\n    if (pathOptions.baseDir) {\n      await writeTextFile(pathOptions.path, content, { baseDir: pathOptions.baseDir })\n    } else {\n      // Handle custom workspace (direct path, no baseDir)\n      await writeTextFile(pathOptions.path, content)\n    }\n    \n    const store = await Store.load('store.json');\n    await store.set('activeFilePath', title)\n    if (isRemove) {\n      deleteTag(currentTagId)\n    }\n    setOpen(false)\n    await loadFileTree()\n    redirect('/core/article')\n  }\n\n  async function readArticleDir() {\n    const workspace = await getWorkspacePath()\n    let folders = []\n    \n    if (workspace.isCustom) {\n      const pathOptions = await getGenericPathOptions('', '')\n      const dirs = (await readDir(pathOptions.path)).filter(dir => dir.isDirectory).map(dir => `/${dir.name}`)\n      folders = dirs\n    } else {\n      const dirs = (await readDir('article', { baseDir: BaseDirectory.AppData })).filter(dir => dir.isDirectory).map(dir => `/${dir.name}`)\n      folders = dirs\n    }\n    \n    setFolders(folders)\n  }\n\n  useEffect(() => {\n    setIsRemove(chat?.tagId !== 1)\n    setTitle(extractTitle(chat?.content || '') + '.md')\n    readArticleDir()\n  }, [chat])\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <a className=\"cursor-pointer flex items-center gap-1 hover:underline\">\n          <SquarePen className=\"size-4\" />\n        </a>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-[525px]\">\n        <DialogHeader>\n          <DialogTitle>{t('note.convert')}</DialogTitle>\n          <DialogDescription>\n            {t('note.description')}\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"flex flex-col gap-2 mt-2\">\n          <Label>{t('note.filename')}</Label>\n          <div className=\"flex border rounded-lg\">\n            <Select value={path} onValueChange={setPath}>\n              <SelectTrigger className=\"w-[180px] border-none outline-none\">\n                <SelectValue placeholder={t('note.selectFolder')} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectGroup>\n                  <SelectItem value=\"/\">{t('note.rootDirectory')}</SelectItem>\n                  {\n                    folders.map((folder, index) => {\n                      return <SelectItem key={index} value={folder}>{folder}</SelectItem>\n                    })\n                  }\n                </SelectGroup>\n              </SelectContent>\n            </Select>\n            <Input className=\"border-none\" value={title} onChange={(e) => setTitle(e.target.value)} />\n          </div>\n          <div className=\"flex items-center space-x-2 mt-2\">\n            <Checkbox disabled={chat?.tagId === 1} id=\"terms\" checked={isRemove} onCheckedChange={value => setIsRemove(value)} />\n            <label\n              htmlFor=\"terms\"\n              className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n            >\n              {t('note.deleteTag')}\n            </label>\n          </div>\n        </div>\n        <DialogFooter>\n          <div className=\"flex items-center justify-end gap-2 pt-4\">\n            <p className=\"text-xs text-zinc-400 flex items-center gap-1\"><TriangleAlert className=\"size-4\" />{t('note.warning')}</p>\n            <Button type=\"submit\" onClick={handleTransform}>{t('note.convert_button')}</Button>\n          </div>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}"
  },
  {
    "path": "src/app/core/main/chat/message-control/read-aloud-control.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Chat } from \"@/db/chats\"\nimport { Volume2, VolumeX, Loader2 } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport { textToSpeechAndPlay, stopCurrentAudio } from \"@/lib/audio\"\nimport useSettingStore from \"@/stores/setting\"\n\ninterface ReadAloudControlProps {\n  chat: Chat\n  translatedContent?: string\n}\n\nexport function ReadAloudControl({ chat, translatedContent }: ReadAloudControlProps) {\n  const t = useTranslations()\n  const [isPlaying, setIsPlaying] = useState(false)\n  const [isLoading, setIsLoading] = useState(false)\n  \n  // 处理朗读/停止\n  async function handleTextToSpeech() {\n    // 如果正在播放，则停止播放\n    if (isPlaying) {\n      stopCurrentAudio()\n      setIsPlaying(false)\n      setIsLoading(false)\n      return\n    }\n    \n    // 如果正在加载或没有内容，则返回\n    if (!chat.content || isLoading) return\n    \n    setIsLoading(true)\n    \n    try {\n      // 使用翻译后的内容或原始内容\n      let textToRead = translatedContent || chat.content\n      \n      // 清理多余的空白字符\n      textToRead = textToRead.trim()\n      \n      if (!textToRead) {\n        console.warn('朗读内容为空')\n        return\n      }\n      \n      // 获取当前音频模型的speed配置\n      const { aiModelList, audioModel } = useSettingStore.getState()\n      const audioConfig = aiModelList.find(config => config.key === audioModel)\n      const speed = audioConfig?.speed\n      \n      // 调用新的音频API，传入voice、speed和状态回调\n      await textToSpeechAndPlay(textToRead, undefined, speed, (playing: boolean) => {\n        setIsPlaying(playing)\n        if (playing) {\n          setIsLoading(false) // 开始播放时清除loading状态\n        }\n      })\n    } catch (error) {\n      console.error('朗读失败:', error)\n      // 可以在这里添加错误提示\n    } finally {\n      setIsLoading(false)\n      setIsPlaying(false)\n    }\n  }\n\n  if (chat.type !== 'chat') {\n    return null\n  }\n\n  return (\n    <>\n      <TooltipButton\n        icon={\n          isLoading ? (\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          ) : isPlaying ? (\n            <VolumeX className=\"h-4 w-4\" />\n          ) : (\n            <Volume2 className=\"h-4 w-4\" />\n          )\n        }\n        tooltipText={\n          isLoading ? t('record.chat.messageControl.loading') : \n          isPlaying ? t('record.chat.messageControl.stop') : \n          t('record.chat.messageControl.readAloud')\n        }\n        onClick={handleTextToSpeech}\n        variant=\"ghost\"\n        size=\"sm\"\n        disabled={isLoading}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/message-control/translate-control.tsx",
    "content": "import { Chat } from \"@/db/chats\"\nimport { GlobeIcon, Loader2 } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport { fetchAiTranslate } from \"@/lib/ai/translate\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { scrollToBottom } from '@/lib/utils'\nimport { TooltipButton } from \"@/components/tooltip-button\"\n\ninterface TranslateControlProps {\n  chat: Chat\n  onTranslatedContent: (content: string) => void\n}\n\nexport function TranslateControl({ chat, onTranslatedContent }: TranslateControlProps) {\n  const translateT = useTranslations('record.chat.input.translate')\n  const [isTranslating, setIsTranslating] = useState(false)\n  const [selectedLanguage, setSelectedLanguage] = useState<string>('')\n  \n  // 可翻译的语言列表\n  const languageOptions = [\n    \"English\",\n    \"中文\",\n    \"日本語\",\n    \"한국어\",\n    \"Français\",\n    \"Deutsch\",\n    \"Español\",\n    \"Русский\",\n  ]\n  \n  // 处理翻译\n  async function handleTranslate(language: string) {\n    if (!chat.content || isTranslating) return\n    \n    setIsTranslating(true)\n    setSelectedLanguage(language)\n    \n    try {\n      const translatedText = await fetchAiTranslate(chat.content, language)\n      onTranslatedContent(translatedText)\n    } catch (error) {\n      console.error('Translation error:', error)\n    } finally {\n      setIsTranslating(false)\n      setTimeout(() => {\n        scrollToBottom()\n      }, 100);\n    }\n  }\n  \n  // 重置翻译\n  function resetTranslation() {\n    setSelectedLanguage('')\n    onTranslatedContent('')\n  }\n\n  if (!chat.content || chat.type !== 'chat') {\n    return null\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <div>\n          <TooltipButton\n            icon={isTranslating ? <Loader2 className=\"size-4 animate-spin\" /> : <GlobeIcon className=\"size-4\" />}\n            tooltipText={translateT('tooltip')}\n            disabled={isTranslating}\n            variant=\"ghost\"\n            size=\"sm\"\n          />\n        </div>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\">\n        {selectedLanguage ? (\n          <DropdownMenuItem onClick={resetTranslation}>\n            {translateT('showOriginal')}\n          </DropdownMenuItem>\n        ) : (\n          languageOptions.map((language) => (\n            <DropdownMenuItem \n              key={language}\n              onClick={() => handleTranslate(language)}\n            >\n              {language}\n            </DropdownMenuItem>\n          ))\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/model-select.tsx",
    "content": "import * as React from \"react\"\nimport { useEffect, useState } from \"react\"\nimport { ModelConfig } from \"../../setting/config\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport useSettingStore from \"@/stores/setting\"\nimport { BotMessageSquare, BotOff } from \"lucide-react\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\"\nimport {\n  Check,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useTranslations } from \"next-intl\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\n\ninterface GroupedModel {\n  configKey: string\n  configTitle: string\n  model: ModelConfig\n}\n\nexport function ModelSelect() {\n  const [groupedModels, setGroupedModels] = useState<GroupedModel[]>([])\n  const { primaryModel, setPrimaryModel, aiModelList } = useSettingStore()\n  const [open, setOpen] = React.useState(false)\n  const t = useTranslations('record.chat.input.modelSelect')\n\n  async function modelSelectChangeHandler(modelId: string) {\n    setPrimaryModel(modelId)\n    const store = await Store.load('store.json');\n    store.set('primaryModel', modelId)\n    await store.save()\n  }\n\n  function handleSetOpen(isOpen: boolean) {\n    setOpen(isOpen)\n  }\n\n  // 监听 aiModelList 变化，处理新的模型配置结构\n  useEffect(() => {\n    if (aiModelList && aiModelList.length > 0) {\n      const models: GroupedModel[] = []\n      \n      aiModelList.forEach(config => {\n        // 检查配置是否有效\n        if (!config.baseURL) return\n        \n        // 处理新的 models 数组结构\n        if (config.models && config.models.length > 0) {\n          config.models.forEach(model => {\n            // 只显示 chat 类型的模型\n            if (model.modelType === 'chat' && model.model) {\n              models.push({\n                configKey: config.key,\n                configTitle: config.title,\n                model: model\n              })\n            }\n          })\n        } else {\n          // 向后兼容：处理旧的单模型结构\n          if ((config.modelType === 'chat' || !config.modelType) && config.model) {\n            models.push({\n              configKey: config.key,\n              configTitle: config.title,\n              model: {\n                id: config.key,\n                model: config.model,\n                modelType: config.modelType || 'chat',\n                temperature: config.temperature,\n                topP: config.topP,\n                voice: config.voice,\n                enableStream: config.enableStream\n              }\n            })\n          }\n        }\n      })\n      \n      setGroupedModels(models)\n    }\n  }, [aiModelList])\n\n  // 按配置分组模型\n  const groupedByConfig = groupedModels.reduce((acc, item) => {\n    if (!acc[item.configTitle]) {\n      acc[item.configTitle] = []\n    }\n    acc[item.configTitle].push(item)\n    return acc\n  }, {} as Record<string, GroupedModel[]>)\n\n  return (\n    <Popover open={open} onOpenChange={handleSetOpen}>\n      <PopoverTrigger asChild>\n        <div className=\"hidden md:block\">\n          <TooltipButton\n            icon={groupedModels.length > 0 ? <BotMessageSquare className=\"size-4\" /> : <BotOff className=\"size-4\" />}\n            tooltipText={t('tooltip')}\n            size=\"icon\"\n          />\n        </div>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[400px] p-0\">\n        <Command>\n          <CommandInput placeholder={t('placeholder')} className=\"h-9\" />\n          <CommandList>\n            <CommandEmpty>{t('noModel')}</CommandEmpty>\n            {Object.entries(groupedByConfig).map(([configTitle, models]) => (\n              <CommandGroup key={configTitle} heading={configTitle}>\n                {models.map((item) => (\n                  <CommandItem\n                    key={item.model.id}\n                    value={item.model.id}\n                    onSelect={(currentValue) => {\n                      modelSelectChangeHandler(currentValue)\n                      setOpen(false)\n                    }}\n                  >\n                    <div className=\"flex flex-col\">\n                      <span className=\"font-medium\">{item.model.model}</span>\n                    </div>\n                    <Check\n                      className={cn(\n                        \"ml-auto size-4\",\n                        primaryModel === item.model.id ? \"opacity-100\" : \"opacity-0\"\n                      )}\n                    />\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            ))}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/new-chat.tsx",
    "content": "\"use client\"\nimport * as React from \"react\"\nimport { MessageSquarePlus } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport useChatStore from \"@/stores/chat\"\nimport { useTranslations } from 'next-intl'\n\nexport function NewChat() {\n  const { startNewConversation, chats } = useChatStore()\n  const t = useTranslations()\n\n  function newChatHandler() {\n    startNewConversation()\n  }\n\n  // 当前会话没有消息时禁用新对话按钮\n  const isDisabled = chats.length === 0\n\n  return (\n    <div>\n      <TooltipButton icon={<MessageSquarePlus />} tooltipText={t('record.chat.input.newChat')} side=\"bottom\" onClick={newChatHandler} disabled={isDisabled}/>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/onboarding-typing.ts",
    "content": "export function buildTypingFrames(text: string, chunkSize: number) {\n  const size = Math.max(1, chunkSize)\n  const frames: string[] = []\n\n  for (let index = size; index < text.length; index += size) {\n    frames.push(text.slice(0, index))\n  }\n\n  frames.push(text)\n  return frames\n}\n"
  },
  {
    "path": "src/app/core/main/chat/prompt-select.tsx",
    "content": "import * as React from \"react\"\nimport { useEffect } from \"react\"\nimport { useTranslations } from \"next-intl\"\nimport { Drama } from \"lucide-react\"\nimport usePromptStore from \"@/stores/prompt\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { Command, CommandGroup, CommandInput, CommandItem, CommandList } from \"@/components/ui/command\"\nimport { cn } from \"@/lib/utils\"\nimport { Check } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\n\nexport function PromptSelect() {\n  const { promptList, currentPrompt, initPromptData, setCurrentPrompt } = usePromptStore()\n  const [open, setOpen] = React.useState(false)\n  const t = useTranslations('record.chat.input.promptSelect')\n\n  // 初始化prompt列表\n  useEffect(() => {\n    initPromptData()\n  }, [])\n\n  // 选择 Prompt\n  async function promptSelectChangeHandler(id: string) {\n    const selectedPrompt = promptList.find(item => item.id === id)\n    if (!selectedPrompt) return\n    await setCurrentPrompt(selectedPrompt)\n  }\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <div className=\"hidden md:block\">\n          <TooltipButton\n            icon={<Drama />}\n            tooltipText={t('tooltip')}\n            size=\"icon\"\n          />\n        </div>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[180px] p-0\">\n        <Command>\n          <CommandInput placeholder={t('tooltip')} className=\"h-9\" />\n          <CommandList>\n            <CommandGroup>\n              {promptList?.map((item) => (\n                <CommandItem\n                  key={item.id}\n                  value={item.id}\n                  onSelect={(currentValue) => {\n                    promptSelectChangeHandler(currentValue)\n                    setOpen(false)\n                  }}\n                >\n                  {item.title}\n                  <Check\n                    className={cn(\n                      \"ml-auto\",\n                      currentPrompt?.id === item.id ? \"opacity-100\" : \"opacity-0\"\n                    )}\n                  />\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/quote-display.tsx",
    "content": "import { X } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport { getQuotePreview } from \"./quote-preview\"\n\ninterface QuoteData {\n  quote: string\n  fullContent: string\n  fileName: string\n  startLine: number\n  endLine: number\n  from: number\n  to: number\n  articlePath: string\n}\n\ninterface QuoteDisplayProps {\n  quoteData: QuoteData\n  onRemove: () => void\n}\n\nexport function QuoteDisplay({ quoteData, onRemove }: QuoteDisplayProps) {\n  const t = useTranslations('editor.quoteDisplay')\n  const { fileName, startLine, endLine, fullContent } = quoteData\n  const [expanded, setExpanded] = useState(false)\n\n  // Generate display text\n  const getDisplayText = () => {\n    if (startLine !== -1 && endLine !== -1) {\n      if (startLine === endLine) {\n        return t('line', { fileName, line: startLine })\n      } else {\n        return t('lines', { fileName, start: startLine, end: endLine })\n      }\n    }\n    return t('fromFile', { fileName })\n  }\n\n  const previewContent = expanded ? fullContent : getQuotePreview(fullContent, 180)\n  const canExpand = fullContent.length > previewContent.length\n\n  return (\n    <div className=\"flex items-start gap-2 p-2 mb-2 border rounded-lg bg-muted/50\">\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"flex items-center gap-2 mb-1\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            {getDisplayText()}\n          </span>\n        </div>\n        <div className={`text-xs text-muted-foreground break-words whitespace-pre-wrap ${expanded ? '' : 'line-clamp-4'}`}>\n          {previewContent}\n        </div>\n        {canExpand && (\n          <button\n            type=\"button\"\n            className=\"mt-1 text-[11px] text-primary\"\n            onClick={() => setExpanded((value) => !value)}\n          >\n            {expanded ? '收起' : '展开'}\n          </button>\n        )}\n      </div>\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"h-6 w-6 shrink-0\"\n        onClick={onRemove}\n      >\n        <X className=\"h-3 w-3\" />\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/quote-preview.ts",
    "content": "export function getQuotePreview(content: string, limit = 160) {\n  if (!content || content.length <= limit) {\n    return content\n  }\n\n  return `${content.slice(0, limit)}...`\n}\n"
  },
  {
    "path": "src/app/core/main/chat/rag-switch.tsx",
    "content": "\"use client\"\n\nimport { useState } from 'react'\nimport { Database, DatabaseZap } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { TooltipButton } from '@/components/tooltip-button'\nimport useVectorStore from '@/stores/vector'\nimport { checkEmbeddingModelAvailable } from '@/lib/rag'\nimport { toast } from '@/hooks/use-toast'\n\nexport function RagSwitch() {\n  const { isRagEnabled, setRagEnabled } = useVectorStore()\n  const t = useTranslations('record.chat.input')\n  const [loading, setLoading] = useState(false)\n\n  const handleToggle = async () => {\n    if (isRagEnabled) {\n      await setRagEnabled(false)\n    } else {\n      setLoading(true)\n      const embeddingModelAvailable = await checkEmbeddingModelAvailable()\n      setLoading(false)\n      if (!embeddingModelAvailable) {\n        toast({\n          variant: \"destructive\",\n          description: t('rag.notSupported')\n        })\n        return\n      }\n      await setRagEnabled(true)\n    }\n  }\n\n  return (\n    <div>\n      <TooltipButton\n        icon={isRagEnabled ? <DatabaseZap className=\"size-4\" /> : <Database className=\"size-4\" />}\n        tooltipText={isRagEnabled ? t('rag.enabled') : t('rag.disabled')}\n        size=\"icon\"\n        side=\"bottom\"\n        onClick={handleToggle}\n        disabled={loading}\n        variant=\"ghost\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/chat/streaming-smoother.ts",
    "content": "export type SmootherState = {\n  carryChars: number;\n  displayedLength: number;\n};\n\nexport type SmootherStepResult = SmootherState & {\n  charsAdded: number;\n};\n\nconst MIN_CHARS_PER_SECOND = 28;\nconst MID_CHARS_PER_SECOND = 52;\nconst HIGH_CHARS_PER_SECOND = 96;\nconst MAX_CHARS_PER_SECOND = 160;\n\nexport function getAdaptiveCharsPerSecond(backlog: number): number {\n  if (backlog > 120) return MAX_CHARS_PER_SECOND;\n  if (backlog > 48) return HIGH_CHARS_PER_SECOND;\n  if (backlog > 16) return MID_CHARS_PER_SECOND;\n  return MIN_CHARS_PER_SECOND;\n}\n\nexport function advanceStreamingSmoother(\n  state: SmootherState,\n  targetLength: number,\n  elapsedMs: number,\n): SmootherStepResult {\n  const safeElapsedMs = Math.max(0, elapsedMs);\n  const backlog = Math.max(0, targetLength - state.displayedLength);\n\n  if (backlog === 0) {\n    return {\n      carryChars: 0,\n      displayedLength: state.displayedLength,\n      charsAdded: 0,\n    };\n  }\n\n  const charsPerSecond = getAdaptiveCharsPerSecond(backlog);\n  const producedChars = state.carryChars + (charsPerSecond * safeElapsedMs) / 1000;\n  let charsToAdd = Math.floor(producedChars);\n\n  // Make the first visible update happen quickly after new content arrives.\n  if (charsToAdd === 0 && safeElapsedMs >= 32) {\n    charsToAdd = 1;\n  }\n\n  charsToAdd = Math.min(charsToAdd, backlog);\n\n  return {\n    carryChars: producedChars - charsToAdd,\n    displayedLength: state.displayedLength + charsToAdd,\n    charsAdded: charsToAdd,\n  };\n}\n"
  },
  {
    "path": "src/app/core/main/editor/editor-layout.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useRef } from 'react'\nimport useArticleStore, { findFolderInTree } from '@/stores/article'\nimport emitter from '@/lib/emitter'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { useTranslations } from 'next-intl'\nimport { useSidebarStore } from '@/stores/sidebar'\nimport useChatStore from '@/stores/chat'\nimport { OnboardingSpotlight } from '@/components/onboarding-spotlight'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { MdEditor } from './markdown/md-editor-wrapper'\nimport { TabBar, TabInfo } from './tab-bar'\nimport { ImageEditor } from './image/image-editor'\nimport { EmptyState } from './empty-state'\nimport { FolderView } from './folder'\nimport { UnsupportedFile } from './unsupported-file'\nimport {\n  createDefaultOnboardingProgress,\n  getCompletionFeedbackMode,\n  getActiveOnboardingStep,\n  markOnboardingStepDone,\n  normalizeOnboardingProgress,\n  type OnboardingProgress,\n  type OnboardingStepId,\n} from './onboarding-state'\nimport {\n  findRecentOnboardingFile,\n  getOnboardingAgentPrompt,\n  getOnboardingSpotlightTarget,\n  ONBOARDING_SAMPLE_RECORD,\n} from './empty-state-actions'\n\n// 常量：扩展名到类型的映射（避免每次渲染时重新创建）\nconst MARKDOWN_EXTENSIONS = new Set([\n  'md', 'txt', 'markdown', 'py', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'less',\n  'html', 'xml', 'json', 'yaml', 'yml', 'sh', 'bash', 'java', 'c', 'cpp', 'h', 'go',\n  'rs', 'sql', 'rb', 'php', 'vue', 'svelte', 'astro', 'toml', 'ini', 'conf', 'cfg',\n  'gitignore', 'env', 'example', 'template'\n])\n\nconst IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'])\nconst ONBOARDING_PROGRESS_STORE_KEY = 'desktopOnboardingProgress'\n\nexport function EditorLayout() {\n  const {\n    activeFilePath,\n    fileTree,\n    setActiveFilePath,\n    openTabs,\n    activeTabId,\n    setOpenTabs,\n    setActiveTabId,\n    addTab,\n    removeTab,\n    initOpenTabs,\n    initShowCloudFiles\n  } = useArticleStore()\n  const { setLeftSidebarTab, rightSidebarVisible, toggleRightSidebar } = useSidebarStore()\n  const { setOnboardingPromptDraft } = useChatStore()\n  const tOnboarding = useTranslations('article.emptyState.onboarding')\n\n  const tabContentsRef = useRef<Record<string, string>>({})\n  const [tabs, setLocalTabs] = useState<TabInfo[]>([])\n  const [localActiveTabId, setLocalActiveTabId] = useState<string>('')\n  const tabsRef = useRef<TabInfo[]>([])\n  const isInitializedRef = useRef(false)\n  const currentOnboardingTaskRef = useRef<OnboardingStepId | null>(null)\n  const [onboardingProgress, setOnboardingProgress] = useState<OnboardingProgress>(createDefaultOnboardingProgress())\n  const [currentOnboardingTask, setCurrentOnboardingTask] = useState<OnboardingStepId | null>(null)\n  const [activeOnboardingStep, setActiveOnboardingStep] = useState<OnboardingStepId | null>(null)\n  const [completedOnboardingStep, setCompletedOnboardingStep] = useState<OnboardingStepId | null>(null)\n  const [showOrganizeNextStepDialog, setShowOrganizeNextStepDialog] = useState(false)\n  const [onboardingResumeFilePath, setOnboardingResumeFilePath] = useState('')\n\n  const persistOnboardingProgress = useCallback(async (progress: OnboardingProgress) => {\n    const store = await Store.load('store.json')\n    await store.set(ONBOARDING_PROGRESS_STORE_KEY, progress)\n    await store.save()\n  }, [])\n\n  // Initialize tabs from store on mount\n  useEffect(() => {\n    if (!isInitializedRef.current) {\n      isInitializedRef.current = true\n      initOpenTabs()\n      initShowCloudFiles()\n    }\n  }, [initOpenTabs, initShowCloudFiles])\n\n  useEffect(() => {\n    const loadOnboardingProgress = async () => {\n      const store = await Store.load('store.json')\n      const savedProgress = await store.get<OnboardingProgress>(ONBOARDING_PROGRESS_STORE_KEY)\n      setOnboardingProgress(normalizeOnboardingProgress(savedProgress))\n    }\n\n    void loadOnboardingProgress()\n  }, [])\n\n  useEffect(() => {\n    currentOnboardingTaskRef.current = currentOnboardingTask\n  }, [currentOnboardingTask])\n\n  useEffect(() => {\n    const handleOnboardingStepComplete = ({\n      step,\n      filePath,\n    }: { step: OnboardingStepId; filePath?: string }) => {\n      setOnboardingProgress((current) => {\n        if (current.steps[step]) {\n          return current\n        }\n\n        const nextProgress = markOnboardingStepDone(current, step)\n        const feedbackMode = getCompletionFeedbackMode(step, currentOnboardingTaskRef.current)\n\n        if (feedbackMode === 'dialog') {\n          const resumeFilePath = filePath || activeFilePath\n          setOnboardingResumeFilePath(resumeFilePath)\n          setCurrentOnboardingTask(null)\n          setActiveOnboardingStep(null)\n          setCompletedOnboardingStep(null)\n          setShowOrganizeNextStepDialog(true)\n        } else if (currentOnboardingTaskRef.current) {\n          setCurrentOnboardingTask(null)\n          setActiveOnboardingStep(null)\n          setCompletedOnboardingStep(step)\n        }\n        void persistOnboardingProgress(nextProgress)\n        return nextProgress\n      })\n    }\n\n    emitter.on('onboarding-step-complete', handleOnboardingStepComplete)\n    return () => {\n      emitter.off('onboarding-step-complete', handleOnboardingStepComplete)\n    }\n  }, [activeFilePath, persistOnboardingProgress])\n\n  // Sync with store\n  useEffect(() => {\n    setLocalTabs(openTabs)\n    tabsRef.current = openTabs\n  }, [openTabs])\n\n  useEffect(() => {\n    setLocalActiveTabId(activeTabId)\n  }, [activeTabId])\n\n  // Helper to check if path is a folder\n  const isFolderPath = useCallback((path: string): boolean => {\n    const fileName = path.split('/').pop() || ''\n    return !fileName.includes('.')\n  }, [])\n\n  // Get item type based on path\n  const getItemType = useCallback((path: string): 'markdown' | 'image' | 'folder' | 'unknown' => {\n    if (!path) return 'unknown'\n\n    // First check if it's a folder\n    const folder = findFolderInTree(path, fileTree)\n    if (folder) return 'folder'\n\n    // Check file extension\n    const extension = path.split('.').pop()?.toLowerCase()\n    if (!extension) return 'unknown'\n\n    if (MARKDOWN_EXTENSIONS.has(extension)) {\n      return 'markdown'\n    }\n    if (IMAGE_EXTENSIONS.has(extension)) {\n      return 'image'\n    }\n    return 'unknown'\n  }, [fileTree])\n\n  // Check if file/folder exists\n  const checkPathExists = useCallback(async (path: string): Promise<boolean> => {\n    const { exists } = await import('@tauri-apps/plugin-fs')\n    const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n    const workspace = await getWorkspacePath()\n    const pathOptions = await getFilePathOptions(path)\n\n    try {\n      if (workspace.isCustom) {\n        return await exists(pathOptions.path)\n      } else {\n        return await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch {\n      return false\n    }\n  }, [])\n\n  // Check if path is a folder in fileTree\n  const isFolderInTree = useCallback((path: string): boolean => {\n    return !!findFolderInTree(path, fileTree)\n  }, [fileTree])\n\n  // Check if path is a file in fileTree\n  const isFileInTree = useCallback((path: string): boolean => {\n    const extension = path.split('.').pop()?.toLowerCase()\n    if (!extension) return false\n\n    const validExtensions = ['md', 'txt', 'markdown', 'py', 'js', 'ts', 'jsx', 'tsx', 'css', 'scss', 'less', 'html', 'xml', 'json', 'yaml', 'yml', 'sh', 'bash', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'sql', 'rb', 'php', 'vue', 'svelte', 'astro', 'toml', 'ini', 'conf', 'cfg', 'gitignore', 'env', 'example', 'template', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']\n\n    if (!validExtensions.includes(extension)) return false\n\n    // Check if file exists in fileTree\n    const checkInTree = (items: typeof fileTree): boolean => {\n      for (const item of items) {\n        if (item.isFile && path.includes(item.name)) return true\n        if (item.children) {\n          if (checkInTree(item.children)) return true\n        }\n      }\n      return false\n    }\n    return checkInTree(fileTree)\n  }, [fileTree])\n\n  // Clean up tabs that no longer exist\n  useEffect(() => {\n    const cleanupTabs = async () => {\n      if (tabs.length === 0) return\n\n      const validTabs: TabInfo[] = []\n      let hasInvalid = false\n\n      for (const tab of tabs) {\n        if (tab.isFolder) {\n          // Check if folder exists in fileTree\n          if (isFolderInTree(tab.path)) {\n            validTabs.push(tab)\n          } else {\n            hasInvalid = true\n          }\n        } else {\n          // Check if file exists in fileTree or on disk\n          if (isFileInTree(tab.path) || await checkPathExists(tab.path)) {\n            validTabs.push(tab)\n          } else {\n            hasInvalid = true\n            // Clean up content cache\n            delete tabContentsRef.current[tab.path]\n          }\n        }\n      }\n\n      if (hasInvalid) {\n        setOpenTabs(validTabs)\n      }\n    }\n\n    cleanupTabs()\n  }, [fileTree, tabs.length, isFolderInTree, isFileInTree, checkPathExists, setOpenTabs])\n\n  // Initialize and update tabs when active path changes\n  useEffect(() => {\n    if (!activeFilePath) return\n\n    const name = activeFilePath.split('/').pop() || activeFilePath\n    const isFolder = isFolderPath(activeFilePath)\n\n    // Check if tab already exists\n    const existingTab = tabsRef.current.find(tab => tab.path === activeFilePath)\n\n    if (existingTab) {\n      // Set as active\n      if (activeTabId !== existingTab.id) {\n        setActiveTabId(existingTab.id)\n      }\n    } else {\n      // Add new tab\n      const newTab: TabInfo = {\n        id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,\n        path: activeFilePath,\n        name: name,\n        isFolder: isFolder\n      }\n      addTab(newTab)\n    }\n  }, [activeFilePath, activeTabId, isFolderPath, addTab, setActiveTabId])\n\n  // Handle tab switch\n  const handleTabSwitch = useCallback((path: string) => {\n    if (path) {\n      setActiveFilePath(path)\n    }\n  }, [setActiveFilePath])\n\n  // Handle new tab button - return to empty state without creating a file\n  const handleNewTab = useCallback(async () => {\n    await Promise.all([\n      setActiveFilePath(''),\n      setActiveTabId(''),\n    ])\n  }, [setActiveFilePath, setActiveTabId])\n\n  // Handle close tab\n  const handleCloseTab = useCallback((closedPath: string) => {\n    // Bug fix: Emit event to clean up loadedPathsRef in MdEditor\n    emitter.emit('editor-file-close', { path: closedPath })\n    delete tabContentsRef.current[closedPath]\n\n    // Get closedTab from the current ref value\n    const closedTab = tabsRef.current.find(t => t.path === closedPath)\n    if (!closedTab) return\n\n    // Save the current tabs count before removing\n    const tabsCountBeforeRemove = tabsRef.current.length\n\n    // Remove the tab\n    removeTab(closedTab.id)\n\n    // Only switch active tab if we're closing the currently active tab\n    if (localActiveTabId === closedTab.id) {\n      if (tabsCountBeforeRemove > 1) {\n        // Find the new target tab from the updated tabsRef after removal\n        const remainingTabs = tabsRef.current.filter(t => t.id !== closedTab.id)\n        if (remainingTabs.length > 0) {\n          // Try to select the tab to the left, otherwise select the last one\n          const currentIndex = tabsRef.current.findIndex(t => t.id === closedTab.id)\n          const targetTab = remainingTabs[Math.max(0, currentIndex - 1)] || remainingTabs[remainingTabs.length - 1]\n          setActiveTabId(targetTab.id)\n          setActiveFilePath(targetTab.path)\n        }\n      } else {\n        setActiveTabId('')\n        setActiveFilePath('')\n      }\n    }\n  }, [localActiveTabId, removeTab, setActiveTabId, setActiveFilePath])\n\n  // Handle close other tabs\n  const handleCloseOtherTabs = useCallback((keepPath: string) => {\n    const tabsToRemove = tabsRef.current.filter(t => t.path !== keepPath)\n\n    tabsToRemove.forEach(tab => {\n      delete tabContentsRef.current[tab.path]\n      removeTab(tab.id)\n    })\n\n    // Update active tab if needed\n    const keptTab = tabsRef.current.find(t => t.path === keepPath)\n    if (keptTab && localActiveTabId !== keptTab.id) {\n      setActiveTabId(keptTab.id)\n      setActiveFilePath(keptTab.path)\n    }\n  }, [localActiveTabId, removeTab, setActiveTabId, setActiveFilePath])\n\n  // Handle close all tabs\n  const handleCloseAllTabs = useCallback(() => {\n    tabsRef.current.forEach(tab => {\n      delete tabContentsRef.current[tab.path]\n      removeTab(tab.id)\n    })\n    setActiveTabId('')\n    setActiveFilePath('')\n  }, [removeTab, setActiveTabId, setActiveFilePath])\n\n  // Handle close left tabs\n  const handleCloseLeftTabs = useCallback((rightPath: string) => {\n    const rightIndex = tabsRef.current.findIndex(t => t.path === rightPath)\n    const tabsToRemove = tabsRef.current.slice(0, rightIndex)\n\n    tabsToRemove.forEach(tab => {\n      delete tabContentsRef.current[tab.path]\n      removeTab(tab.id)\n    })\n\n    // Update active tab if needed\n    if (rightIndex > 0) {\n      const rightTab = tabsRef.current[rightIndex]\n      if (rightTab && localActiveTabId !== rightTab.id) {\n        setActiveTabId(rightTab.id)\n        setActiveFilePath(rightTab.path)\n      }\n    }\n  }, [localActiveTabId, removeTab, setActiveTabId, setActiveFilePath])\n\n  // Handle close right tabs\n  const handleCloseRightTabs = useCallback((leftPath: string) => {\n    const leftIndex = tabsRef.current.findIndex(t => t.path === leftPath)\n    const tabsToRemove = tabsRef.current.slice(leftIndex + 1)\n\n    tabsToRemove.forEach(tab => {\n      delete tabContentsRef.current[tab.path]\n      removeTab(tab.id)\n    })\n  }, [removeTab])\n\n  const onboardingAgentPrompt = getOnboardingAgentPrompt({\n    intro: tOnboarding('agentPrompt.intro'),\n    requirements: [\n      tOnboarding('agentPrompt.requirement1'),\n      tOnboarding('agentPrompt.requirement2'),\n      tOnboarding('agentPrompt.requirement3'),\n      tOnboarding('agentPrompt.requirement4'),\n    ],\n    outro: tOnboarding('agentPrompt.outro'),\n  })\n\n  const handleStartOnboardingStep = useCallback(async (step: OnboardingStepId) => {\n    if (onboardingProgress.dismissed) {\n      const nextProgress = {\n        ...onboardingProgress,\n        dismissed: false,\n      }\n      setOnboardingProgress(nextProgress)\n      await persistOnboardingProgress(nextProgress)\n    }\n\n    setCurrentOnboardingTask(step)\n    setActiveOnboardingStep(step)\n    setCompletedOnboardingStep(null)\n    setShowOrganizeNextStepDialog(false)\n\n    if (step === 'create-record') {\n      emitter.emit('onboarding-record-prefill-changed', {\n        prefillText: ONBOARDING_SAMPLE_RECORD,\n      })\n      await setLeftSidebarTab('notes')\n      return\n    }\n\n    if (step === 'organize-note') {\n      await setLeftSidebarTab('notes')\n      return\n    }\n\n    if (step === 'ai-polish') {\n      const candidateResumeFilePath = findRecentOnboardingFile({\n        preferredPath: onboardingResumeFilePath,\n        activeFilePath,\n        openTabPaths: openTabs.map((tab) => tab.path),\n        fileTree,\n      })\n      const resolvedResumeFilePath = candidateResumeFilePath && await checkPathExists(candidateResumeFilePath)\n        ? candidateResumeFilePath\n        : ''\n\n      if (!rightSidebarVisible) {\n        await toggleRightSidebar()\n      }\n      if (resolvedResumeFilePath) {\n        await setActiveFilePath(resolvedResumeFilePath)\n      }\n      await new Promise((resolve) => window.setTimeout(resolve, 120))\n      setOnboardingPromptDraft(onboardingAgentPrompt)\n    }\n  }, [activeFilePath, fileTree, onboardingAgentPrompt, onboardingProgress, onboardingResumeFilePath, openTabs, persistOnboardingProgress, rightSidebarVisible, setActiveFilePath, setLeftSidebarTab, setOnboardingPromptDraft, toggleRightSidebar])\n\n  const handleDismissOnboarding = useCallback(async () => {\n    const nextProgress = {\n      ...onboardingProgress,\n      dismissed: true,\n    }\n\n    setOnboardingProgress(nextProgress)\n    setCurrentOnboardingTask(null)\n    setActiveOnboardingStep(null)\n    setCompletedOnboardingStep(null)\n    setShowOrganizeNextStepDialog(false)\n    await persistOnboardingProgress(nextProgress)\n  }, [onboardingProgress, persistOnboardingProgress])\n\n  const handleDismissSpotlight = useCallback(() => {\n    setActiveOnboardingStep(null)\n  }, [])\n\n  const handleDismissOrganizeNextStepDialog = useCallback(() => {\n    setShowOrganizeNextStepDialog(false)\n  }, [])\n\n  const handleAcceptOrganizeNextStepDialog = useCallback(async () => {\n    setShowOrganizeNextStepDialog(false)\n    setCompletedOnboardingStep('organize-note')\n    await Promise.all([\n      setActiveFilePath(''),\n      setActiveTabId(''),\n    ])\n  }, [setActiveFilePath, setActiveTabId])\n\n  const handleContinueToNextStep = useCallback(() => {\n    const nextStep = getActiveOnboardingStep(onboardingProgress)\n    setCompletedOnboardingStep(null)\n    if (nextStep) {\n      void handleStartOnboardingStep(nextStep)\n    }\n  }, [handleStartOnboardingStep, onboardingProgress])\n\n  const spotlightTitle = activeOnboardingStep ? tOnboarding(`spotlight.${activeOnboardingStep}.title`) : ''\n  const spotlightDescription = activeOnboardingStep ? tOnboarding(`spotlight.${activeOnboardingStep}.desc`) : ''\n\n  // Render content panel for a tab\n  const renderContentPanel = useCallback((tab: TabInfo, isActive: boolean) => {\n    const itemType = getItemType(tab.path)\n\n    return (\n      <div\n        key={tab.id}\n        className=\"w-full h-[calc(100%-48px)]\"\n        style={{ display: isActive ? 'flex' : 'none' }}\n      >\n        {itemType === 'folder' && (\n          <FolderView folderPath={tab.path} />\n        )}\n        {itemType === 'image' && (\n          <ImageEditor filePath={tab.path} />\n        )}\n        {itemType === 'markdown' && (\n          <MdEditor\n            key={tab.id}\n            tabContentsRef={tabContentsRef}\n            filePath={tab.path}\n          />\n        )}\n        {itemType === 'unknown' && (\n          <UnsupportedFile filePath={tab.path} />\n        )}\n      </div>\n    )\n  }, [getItemType])\n\n  // No tabs or no active tab - show empty state\n  if (tabs.length === 0 || !activeTabId) {\n    return (\n      <div className=\"flex-1 relative w-full h-full flex flex-col overflow-hidden\">\n        <TabBar\n          tabs={tabs}\n          activeTabId={activeTabId}\n          onTabSwitch={handleTabSwitch}\n          onNewTab={handleNewTab}\n          onCloseTab={handleCloseTab}\n          onCloseOtherTabs={handleCloseOtherTabs}\n          onCloseAllTabs={handleCloseAllTabs}\n          onCloseLeftTabs={handleCloseLeftTabs}\n          onCloseRightTabs={handleCloseRightTabs}\n        />\n        <EmptyState\n          onboardingProgress={onboardingProgress}\n          activeOnboardingStep={currentOnboardingTask}\n          visibleOnboardingStep={activeOnboardingStep}\n          completedOnboardingStep={completedOnboardingStep}\n          onStartOnboardingStep={handleStartOnboardingStep}\n          onContinueToNextStep={handleContinueToNextStep}\n          onDismissOnboarding={handleDismissOnboarding}\n        />\n        <OnboardingSpotlight\n          targetId={activeOnboardingStep ? getOnboardingSpotlightTarget(activeOnboardingStep) : null}\n          title={spotlightTitle}\n          description={spotlightDescription}\n          onDismiss={handleDismissSpotlight}\n        />\n        <Dialog open={showOrganizeNextStepDialog} onOpenChange={setShowOrganizeNextStepDialog}>\n          <DialogContent className=\"sm:max-w-md\">\n            <DialogHeader>\n              <DialogTitle>{tOnboarding('afterOrganizeDialog.title')}</DialogTitle>\n              <DialogDescription>{tOnboarding('afterOrganizeDialog.description')}</DialogDescription>\n            </DialogHeader>\n            <DialogFooter>\n              <button\n                onClick={handleDismissOrganizeNextStepDialog}\n                className=\"inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground\"\n              >\n                {tOnboarding('afterOrganizeDialog.cancel')}\n              </button>\n              <button\n                onClick={() => void handleAcceptOrganizeNextStepDialog()}\n                className=\"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90\"\n              >\n                {tOnboarding('afterOrganizeDialog.confirm')}\n              </button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex-1 relative w-full h-full flex flex-col overflow-hidden\">\n      {/* Tab Bar */}\n      <TabBar\n        tabs={tabs}\n        activeTabId={localActiveTabId}\n        onTabSwitch={handleTabSwitch}\n        onNewTab={handleNewTab}\n        onCloseTab={handleCloseTab}\n        onCloseOtherTabs={handleCloseOtherTabs}\n        onCloseAllTabs={handleCloseAllTabs}\n        onCloseLeftTabs={handleCloseLeftTabs}\n        onCloseRightTabs={handleCloseRightTabs}\n      />\n\n      {/* Only render active tab content - improves performance with many tabs */}\n      {tabs.filter(tab => tab.id === localActiveTabId).map(tab => renderContentPanel(tab, true))}\n      <OnboardingSpotlight\n        targetId={activeOnboardingStep ? getOnboardingSpotlightTarget(activeOnboardingStep) : null}\n        title={spotlightTitle}\n        description={spotlightDescription}\n        onDismiss={handleDismissSpotlight}\n      />\n      <Dialog open={showOrganizeNextStepDialog} onOpenChange={setShowOrganizeNextStepDialog}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>{tOnboarding('afterOrganizeDialog.title')}</DialogTitle>\n            <DialogDescription>{tOnboarding('afterOrganizeDialog.description')}</DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <button\n              onClick={handleDismissOrganizeNextStepDialog}\n              className=\"inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground\"\n            >\n              {tOnboarding('afterOrganizeDialog.cancel')}\n            </button>\n            <button\n              onClick={() => void handleAcceptOrganizeNextStepDialog()}\n              className=\"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90\"\n            >\n              {tOnboarding('afterOrganizeDialog.confirm')}\n            </button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/empty-state-actions.ts",
    "content": "export async function createNewNoteFromEmptyState({\n  setLeftSidebarTab,\n  newFile,\n}: {\n  setLeftSidebarTab: (tab: 'files' | 'notes') => void | Promise<void>\n  newFile: () => void | Promise<void>\n}) {\n  await setLeftSidebarTab('files')\n  await newFile()\n}\n\nexport const ONBOARDING_SAMPLE_RECORD = `这是我在 NoteGen 里的第一条记录。\n\n我可以先把零散想法快速记下来，不用一开始就整理结构。\n后面可以把这些记录整理成一篇正式笔记，再继续编辑。\n如果觉得内容不够通顺，还可以用 AI 帮我润色、改写或补充重点。\n写完之后，笔记会保存在本地 Markdown 文件里，也方便后续查找和管理。`\n\nexport async function startCreateRecordOnboardingStep({\n  setLeftSidebarTab,\n  openQuickRecord,\n}: {\n  setLeftSidebarTab: (tab: 'files' | 'notes') => void | Promise<void>\n  openQuickRecord: (payload: { prefillText: string }) => void | Promise<void>\n}) {\n  await setLeftSidebarTab('notes')\n  await openQuickRecord({ prefillText: ONBOARDING_SAMPLE_RECORD })\n}\n\nexport function getOnboardingAgentPrompt({\n  intro,\n  requirements,\n  outro,\n}: {\n  intro: string\n  requirements: string[]\n  outro: string\n}) {\n  return [intro, requirements.filter(Boolean).join('\\n'), outro]\n    .filter(Boolean)\n    .join('\\n\\n')\n}\n\nexport function getOnboardingSpotlightTarget(step: 'create-record' | 'organize-note' | 'ai-polish') {\n  switch (step) {\n    case 'create-record':\n      return 'onboarding-target-record-toolbar'\n    case 'organize-note':\n      return 'onboarding-target-organize-notes'\n    case 'ai-polish':\n      return 'onboarding-target-chat-input'\n  }\n}\n\nfunction isMarkdownPath(path: string) {\n  return /\\.(md|txt|markdown)$/i.test(path)\n}\n\ntype OnboardingFileTreeNode = {\n  name: string\n  parent?: OnboardingFileTreeNode\n  children?: OnboardingFileTreeNode[]\n  isFile?: boolean\n  isDirectory?: boolean\n  isSymlink?: boolean\n  isLocale?: boolean\n  createdAt?: string\n  modifiedAt?: string\n}\n\nfunction computedOnboardingPath(node: OnboardingFileTreeNode): string {\n  const segments: string[] = []\n  let current: OnboardingFileTreeNode | undefined = node\n\n  while (current) {\n    if (current.name) {\n      segments.unshift(current.name)\n    }\n    current = current.parent\n  }\n\n  return segments.join('/')\n}\n\nfunction getPathPriority(path: string) {\n  const name = path.split('/').pop() || path\n\n  if (/^整理笔记_\\d+\\.md$/i.test(name)) {\n    return 2\n  }\n\n  if (isMarkdownPath(path)) {\n    return 1\n  }\n\n  return 0\n}\n\nfunction flattenFileTree(tree: OnboardingFileTreeNode[]): Array<{ path: string; modifiedAt?: string; createdAt?: string }> {\n  return tree.flatMap((item) => {\n    const currentPath = computedOnboardingPath(item)\n\n    if (item.isFile) {\n      return [{\n        path: currentPath,\n        modifiedAt: item.modifiedAt,\n        createdAt: item.createdAt,\n      }]\n    }\n\n    const childNodes = item.children\n    if (!childNodes?.length) {\n      return []\n    }\n\n    return flattenFileTree(childNodes)\n  })\n}\n\nexport function findRecentOnboardingFile({\n  preferredPath,\n  activeFilePath,\n  openTabPaths,\n  fileTree,\n}: {\n  preferredPath?: string\n  activeFilePath?: string\n  openTabPaths?: string[]\n  fileTree: OnboardingFileTreeNode[]\n}) {\n  if (preferredPath && isMarkdownPath(preferredPath)) {\n    return preferredPath\n  }\n\n  const explicitCandidates = [activeFilePath, ...(openTabPaths || [])]\n    .filter((path): path is string => typeof path === 'string' && isMarkdownPath(path))\n\n  const fileCandidates = flattenFileTree(fileTree)\n    .filter((file) => isMarkdownPath(file.path))\n    .sort((a, b) => {\n      const priorityDiff = getPathPriority(b.path) - getPathPriority(a.path)\n      if (priorityDiff !== 0) {\n        return priorityDiff\n      }\n\n      const aModified = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0\n      const bModified = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0\n      if (aModified !== bModified) {\n        return bModified - aModified\n      }\n\n      const aCreated = a.createdAt ? new Date(a.createdAt).getTime() : 0\n      const bCreated = b.createdAt ? new Date(b.createdAt).getTime() : 0\n      return bCreated - aCreated\n    })\n    .map((file) => file.path)\n\n  return [...explicitCandidates, ...fileCandidates].find(Boolean) || ''\n}\n"
  },
  {
    "path": "src/app/core/main/editor/empty-state.tsx",
    "content": "'use client'\n\nimport { FileText, MessageSquareText, Search, FolderOpen } from 'lucide-react'\nimport useArticleStore from '@/stores/article'\nimport { useTranslations } from 'next-intl'\nimport { open } from '@tauri-apps/plugin-dialog'\nimport { Store } from '@tauri-apps/plugin-store'\nimport Image from 'next/image'\nimport emitter from '@/lib/emitter'\nimport { useEffect, useState } from 'react'\nimport useShortcutStore from '@/stores/shortcut'\nimport useSettingStore from '@/stores/setting'\nimport { useSidebarStore } from '@/stores/sidebar'\nimport { getActiveOnboardingStep, getNextOnboardingStep, type OnboardingProgress, type OnboardingStepId } from './onboarding-state'\nimport { createNewNoteFromEmptyState } from './empty-state-actions'\n\ninterface ActionItem {\n  icon: React.ReactNode\n  title: string\n  description: string\n  shortcut?: string\n  onClick: () => void\n}\n\ninterface EmptyStateProps {\n  onboardingProgress: OnboardingProgress\n  activeOnboardingStep: OnboardingStepId | null\n  visibleOnboardingStep: OnboardingStepId | null\n  completedOnboardingStep: OnboardingStepId | null\n  onStartOnboardingStep: (step: OnboardingStepId) => void | Promise<void>\n  onContinueToNextStep: () => void | Promise<void>\n  onDismissOnboarding: () => void | Promise<void>\n}\n\nexport function EmptyState({\n  onboardingProgress,\n  activeOnboardingStep,\n  visibleOnboardingStep,\n  completedOnboardingStep,\n  onStartOnboardingStep,\n  onContinueToNextStep,\n  onDismissOnboarding,\n}: EmptyStateProps) {\n  const { newFile } = useArticleStore()\n  const { setLeftSidebarTab } = useSidebarStore()\n  const t = useTranslations('article.emptyState')\n  const { shortcuts } = useShortcutStore()\n  const { addWorkspaceHistory } = useSettingStore()\n  const [textRecordShortcut, setTextRecordShortcut] = useState('')\n\n  const handleCreateNote = async () => {\n    await createNewNoteFromEmptyState({\n      setLeftSidebarTab,\n      newFile,\n    })\n  }\n\n  // 注册快捷键\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Cmd/Ctrl + N 创建笔记\n      if ((e.metaKey || e.ctrlKey) && e.key === 'n') {\n        e.preventDefault()\n        void handleCreateNote()\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [newFile, setLeftSidebarTab])\n\n  // 读取文本记录快捷键\n  useEffect(() => {\n    const shortcut = shortcuts.find(s => s.key === 'quickRecordText')\n    if (shortcut) {\n      // 转换快捷键格式：CommandOrControl+Shift+T -> ⌘ ⇧ T\n      const formatted = shortcut.value\n        .replace('CommandOrControl', '⌘')\n        .replace('Command', '⌘')\n        .replace('Control', 'Ctrl')\n        .replace('Shift', '⇧')\n        .replace('Alt', '⌥')\n        .replace('+', ' ')\n      setTextRecordShortcut(formatted)\n    }\n  }, [shortcuts])\n\n  const handleOpenWorkspace = async () => {\n    try {\n      const selected = await open({\n        directory: true,\n        multiple: false,\n        title: '选择工作区目录'\n      })\n      \n      if (selected && typeof selected === 'string') {\n        const store = await Store.load('store.json')\n        await store.set('workspacePath', selected)\n        await store.save()\n        \n        // 添加到历史记录\n        await addWorkspaceHistory(selected)\n        \n        // 重新加载页面以应用新工作区\n        window.location.reload()\n      }\n    } catch (error) {\n      console.error('Failed to open workspace:', error)\n    }\n  }\n\n  const handleOpenRecord = () => {\n    // 触发文本记录弹窗\n    emitter.emit('quickRecordTextHandler')\n  }\n\n  const handleGlobalSearch = () => {\n    // 触发全局搜索弹窗 (Cmd/Ctrl + F)\n    const event = new KeyboardEvent('keydown', {\n      key: 'f',\n      metaKey: true,\n      ctrlKey: true,\n      bubbles: true\n    })\n    window.dispatchEvent(event)\n  }\n\n  const actions: ActionItem[] = [\n    {\n      icon: <FileText className=\"w-5 h-5\" />,\n      title: t('actions.newNote.title'),\n      description: t('actions.newNote.desc'),\n      shortcut: '⌘ N',\n      onClick: () => void handleCreateNote()\n    },\n    {\n      icon: <MessageSquareText className=\"w-5 h-5\" />,\n      title: t('actions.newRecord.title'),\n      description: t('actions.newRecord.desc'),\n      shortcut: textRecordShortcut,\n      onClick: handleOpenRecord\n    },\n    {\n      icon: <Search className=\"w-5 h-5\" />,\n      title: t('actions.globalSearch.title'),\n      description: t('actions.globalSearch.desc'),\n      shortcut: '⌘ F',\n      onClick: handleGlobalSearch\n    },\n    {\n      icon: <FolderOpen className=\"w-5 h-5\" />,\n      title: t('actions.openWorkspace.title'),\n      description: t('actions.openWorkspace.desc'),\n      onClick: handleOpenWorkspace\n    }\n  ]\n\n  const onboardingSteps: Array<{ id: OnboardingStepId; title: string; description: string }> = [\n    {\n      id: 'create-record',\n      title: t('onboarding.steps.createRecord.title'),\n      description: t('onboarding.steps.createRecord.desc'),\n    },\n    {\n      id: 'organize-note',\n      title: t('onboarding.steps.organizeNote.title'),\n      description: t('onboarding.steps.organizeNote.desc'),\n    },\n    {\n      id: 'ai-polish',\n      title: t('onboarding.steps.aiPolish.title'),\n      description: t('onboarding.steps.aiPolish.desc'),\n    },\n  ]\n  const completedStep = onboardingSteps.find((step) => step.id === completedOnboardingStep) || null\n  const nextOnboardingStepId = getNextOnboardingStep(onboardingProgress, completedOnboardingStep)\n  const hasPendingNextStep = getActiveOnboardingStep(onboardingProgress) !== null\n  const currentOnboardingStep = onboardingSteps.find((step) => step.id === activeOnboardingStep)\n    || onboardingSteps.find((step) => step.id === nextOnboardingStepId)\n    || null\n  const currentOnboardingIndex = currentOnboardingStep\n    ? onboardingSteps.findIndex((step) => step.id === currentOnboardingStep.id)\n    : -1\n  const completedOnboardingIndex = completedStep\n    ? onboardingSteps.findIndex((step) => step.id === completedStep.id)\n    : -1\n  const showCompletedCard = Boolean(completedStep && hasPendingNextStep)\n  const showOnboardingCard = !onboardingProgress.dismissed && (showCompletedCard || Boolean(currentOnboardingStep))\n\n  return (\n    <div className=\"flex-1 flex flex-col items-center justify-center h-full bg-background p-8\">\n      <div className=\"max-w-2xl w-full space-y-8\">\n        {/* Header */}\n        <div className=\"text-center space-y-3\">\n          <div className=\"flex items-center justify-center gap-3 mb-2\">\n            <Image \n              src=\"/app-icon.png\" \n              alt=\"NoteGen\" \n              width={60}\n              height={60}\n              className=\"w-10 h-10 dark:invert\"\n            />\n            <h1 className=\"text-4xl font-bold tracking-tight\">\n              NoteGen\n            </h1>\n          </div>\n          <h2 className=\"text-xl font-semibold tracking-tight\">\n            {t('title')}\n          </h2>\n          <p className=\"text-muted-foreground text-sm\">\n            {t('subtitle')}\n          </p>\n        </div>\n\n        {showOnboardingCard && (\n          <div className=\"rounded-2xl border bg-card/80 p-5 shadow-sm\">\n            <div className=\"flex items-start justify-between gap-4\">\n              <div className=\"space-y-1\">\n                <h3 className=\"text-base font-semibold\">{t('onboarding.title')}</h3>\n                <p className=\"text-sm text-muted-foreground\">{t('onboarding.subtitle')}</p>\n              </div>\n              <button\n                onClick={() => void onDismissOnboarding()}\n                className=\"shrink-0 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                {t('onboarding.dismiss')}\n              </button>\n            </div>\n\n            {showCompletedCard && completedStep ? (\n              <div className=\"mt-4 rounded-xl border border-emerald-500/50 bg-emerald-500/5 p-4 transition-colors\">\n                <div className=\"flex items-start justify-between gap-4\">\n                  <div className=\"space-y-1\">\n                    <p className=\"text-xs uppercase tracking-wide text-emerald-700/80 dark:text-emerald-300/80\">\n                      {t('onboarding.stepCompletedLabel', { current: completedOnboardingIndex + 1, total: onboardingSteps.length })}\n                    </p>\n                    <h4 className=\"text-sm font-medium text-emerald-800 dark:text-emerald-200\">\n                      {t(`onboarding.completedStates.${completedStep.id}.title`)}\n                    </h4>\n                    <p className=\"text-xs text-emerald-700/80 dark:text-emerald-300/80\">\n                      {t(`onboarding.completedStates.${completedStep.id}.desc`)}\n                    </p>\n                  </div>\n                  <button\n                    onClick={() => void onContinueToNextStep()}\n                    className=\"shrink-0 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-500\"\n                  >\n                    {t('onboarding.continue')}\n                  </button>\n                </div>\n              </div>\n            ) : currentOnboardingStep ? (\n              <div className=\"mt-4 rounded-xl border border-primary/60 bg-primary/5 p-4 transition-colors\">\n                <div className=\"flex items-start justify-between gap-4\">\n                  <div className=\"space-y-1\">\n                    <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">\n                      {t('onboarding.stepLabel', { current: currentOnboardingIndex + 1, total: onboardingSteps.length })}\n                    </p>\n                    <h4 className=\"text-sm font-medium\">{currentOnboardingStep.title}</h4>\n                    <p className=\"text-xs text-muted-foreground\">{currentOnboardingStep.description}</p>\n                  </div>\n                  <button\n                    onClick={() => void onStartOnboardingStep(currentOnboardingStep.id)}\n                    className=\"shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:opacity-90\"\n                  >\n                    {visibleOnboardingStep === currentOnboardingStep.id ? t('onboarding.viewHint') : t('onboarding.start')}\n                  </button>\n                </div>\n              </div>\n            ) : null}\n          </div>\n        )}\n\n        {/* Actions Grid */}\n        <div className=\"grid grid-cols-1 md:grid-cols-2 gap-3\">\n          {actions.map((action, index) => (\n            <button\n              key={index}\n              onClick={action.onClick}\n              className=\"group relative flex items-start gap-4 p-4 rounded-lg border bg-card hover:bg-accent hover:border-primary/50 transition-all duration-200 text-left\"\n            >\n              <div className=\"flex-shrink-0 mt-1 text-muted-foreground group-hover:text-primary transition-colors\">\n                {action.icon}\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"flex items-center justify-between gap-2\">\n                  <h3 className=\"font-medium text-sm\">\n                    {action.title}\n                  </h3>\n                  {action.shortcut && (\n                    <kbd className=\"hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100\">\n                      {action.shortcut}\n                    </kbd>\n                  )}\n                </div>\n                <p className=\"text-xs text-muted-foreground mt-1\">\n                  {action.description}\n                </p>\n              </div>\n            </button>\n          ))}\n        </div>\n\n        {/* Tips */}\n        <div className=\"text-center space-y-2 pt-4\">\n          <p className=\"text-xs text-muted-foreground\">\n            查看使用文档：\n            <a \n              href=\"https://notegen.top/\" \n              target=\"_blank\" \n              rel=\"noopener noreferrer\"\n              className=\"text-primary hover:underline ml-1\"\n            >\n              https://notegen.top/\n            </a>\n          </p>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/folder/folder-stats.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useCallback } from 'react'\nimport { Folder, Database, Clock, RefreshCw, Loader2, FileText } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Progress } from '@/components/ui/progress'\nimport { useTranslations } from 'next-intl'\nimport useArticleStore from '@/stores/article'\nimport { getVectorDocumentsByFilename } from '@/db/vector'\nimport dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport 'dayjs/locale/zh-cn'\nimport { calculateFolderVectors } from '@/lib/folder-vector'\nimport { toast } from '@/hooks/use-toast'\n\ndayjs.extend(relativeTime)\ndayjs.locale('zh-cn')\n\ninterface FolderStats {\n  totalFiles: number\n  indexedFiles: number\n  totalVectors: number\n  databaseSize: string\n  lastUpdated: string | null\n}\n\ninterface FolderStatsViewProps {\n  folderPath: string\n  folderFiles: string[]\n}\n\nexport function FolderStatsView({ folderPath, folderFiles }: FolderStatsViewProps) {\n  const t = useTranslations('article.file.folderView')\n  const [stats, setStats] = useState<FolderStats | null>(null)\n  const [loadingStats, setLoadingStats] = useState(false)\n  const [vectorFilesInitialized, setVectorFilesInitialized] = useState(false)\n  const [batchProgress, setBatchProgress] = useState<{\n    total: number\n    processed: number\n    failed: number\n    currentFile: string\n  } | null>(null)\n\n  const { vectorIndexedFiles, initVectorIndexedFiles, setVectorCalcStatus } = useArticleStore()\n\n  const folderName = folderPath.split('/').pop() || folderPath\n\n  // Calculate folder statistics\n  const calculateStats = useCallback(async () => {\n    setLoadingStats(true)\n\n    try {\n      const totalFiles = folderFiles.length\n      const indexedFiles = folderFiles.filter(file => vectorIndexedFiles.has(file)).length\n\n      let totalVectors = 0\n      for (const file of folderFiles) {\n        if (vectorIndexedFiles.has(file)) {\n          const docs = await getVectorDocumentsByFilename(file)\n          totalVectors += docs.length\n        }\n      }\n\n      const dbSizeBytes = totalVectors * 2048\n      const dbSizeMB = (dbSizeBytes / (1024 * 1024)).toFixed(2)\n      const databaseSize = dbSizeBytes < 1024 * 1024\n        ? `${(dbSizeBytes / 1024).toFixed(2)} KB`\n        : `${dbSizeMB} MB`\n\n      const timestamps = Array.from(vectorIndexedFiles.values())\n      const lastUpdated = timestamps.length > 0\n        ? dayjs(Math.max(...timestamps)).fromNow()\n        : null\n\n      setStats({\n        totalFiles,\n        indexedFiles,\n        totalVectors,\n        databaseSize,\n        lastUpdated\n      })\n    } catch (error) {\n      console.error('Failed to calculate folder stats:', error)\n    } finally {\n      setLoadingStats(false)\n    }\n  }, [folderFiles, vectorIndexedFiles])\n\n  // 确保 vectorIndexedFiles 被初始化\n  useEffect(() => {\n    const init = async () => {\n      await initVectorIndexedFiles()\n      setVectorFilesInitialized(true)\n    }\n    init()\n  }, [initVectorIndexedFiles])\n\n  // Initial stats calculation - 等待 vectorIndexedFiles 初始化完成\n  useEffect(() => {\n    if (vectorFilesInitialized) {\n      calculateStats()\n    }\n  }, [calculateStats, vectorFilesInitialized])\n\n  // Start batch recalculation\n  const startRecalculation = useCallback(async () => {\n    if (folderFiles.length === 0) return\n\n    setBatchProgress({\n      total: folderFiles.length,\n      processed: 0,\n      failed: 0,\n      currentFile: ''\n    })\n\n    setVectorCalcStatus(folderPath, 'calculating')\n\n    const result = await calculateFolderVectors({\n      folderPath,\n      mode: 'recalculate',\n      setVectorCalcStatus,\n      onProgress: setBatchProgress\n    })\n\n    if (!result.embeddingModelAvailable) {\n      toast({\n        title: '向量处理',\n        description: '未配置嵌入模型或模型不可用，请在AI设置中配置嵌入模型',\n        variant: 'destructive'\n      })\n      setVectorCalcStatus(folderPath, 'idle')\n      setBatchProgress(null)\n      return\n    }\n\n    // Refresh vector indexed files list for calculateStats to get latest data\n    await useArticleStore.getState().initVectorIndexedFiles()\n    await calculateStats()\n    setVectorCalcStatus(folderPath, result.failed > 0 ? 'idle' : 'completed')\n    setBatchProgress(null)\n  }, [folderFiles, folderPath, calculateStats, setVectorCalcStatus])\n\n  if (loadingStats && !stats) {\n    return (\n      <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-muted-foreground\" />\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background gap-6 p-8\">\n      {/* Folder Icon and Name */}\n      <div className=\"flex flex-col items-center gap-3\">\n        <Folder className=\"w-20 h-20 text-muted-foreground\" />\n        <h2 className=\"text-2xl font-semibold tracking-tight\">{folderName}</h2>\n      </div>\n\n      {/* Stats Display */}\n      {stats && (\n        <div className=\"flex flex-col gap-3 w-full max-w-md\">\n          {/* Indexed Files Count */}\n          <div className=\"flex items-center justify-between text-sm py-2 border-b\">\n            <span className=\"text-muted-foreground flex items-center gap-2\">\n              <FileText className=\"w-4 h-4\" />\n              {t('indexed')}\n            </span>\n            <span className=\"font-medium\">\n              {stats.indexedFiles} / {stats.totalFiles}\n            </span>\n          </div>\n\n          {/* Total Vectors */}\n          <div className=\"flex items-center justify-between text-sm py-2 border-b\">\n            <span className=\"text-muted-foreground flex items-center gap-2\">\n              <Database className=\"w-4 h-4\" />\n              {t('vectorCount')}\n            </span>\n            <span className=\"font-medium\">{stats.totalVectors}</span>\n          </div>\n\n          {/* Database Size */}\n          <div className=\"flex items-center justify-between text-sm py-2 border-b\">\n            <span className=\"text-muted-foreground flex items-center gap-2\">\n              <Database className=\"w-4 h-4\" />\n              {t('databaseSize')}\n            </span>\n            <span className=\"font-medium\">{stats.databaseSize}</span>\n          </div>\n\n          {/* Last Updated */}\n          <div className=\"flex items-center justify-between text-sm py-2\">\n            <span className=\"text-muted-foreground flex items-center gap-2\">\n              <Clock className=\"w-4 h-4\" />\n              {t('lastCalculated')}\n            </span>\n            <span className=\"font-medium\">\n              {stats.lastUpdated || t('never')}\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Progress Bar during batch processing */}\n      {batchProgress && (\n        <div className=\"w-full max-w-md space-y-2\">\n          <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n            <span>{t('calculating')}</span>\n            <span>{batchProgress.processed} / {batchProgress.total}</span>\n          </div>\n          <Progress value={(batchProgress.processed / batchProgress.total) * 100} className=\"h-2\" />\n          {batchProgress.failed > 0 && (\n            <p className=\"text-xs text-destructive\">\n              {t('failed')}: {batchProgress.failed}\n            </p>\n          )}\n        </div>\n      )}\n\n      {/* Recalculate Button */}\n      <Button\n        variant=\"outline\"\n        onClick={startRecalculation}\n        disabled={!!batchProgress || !stats || stats.totalFiles === 0}\n        className=\"gap-2\"\n      >\n        <RefreshCw className={`w-4 h-4 ${batchProgress ? 'animate-spin' : ''}`} />\n        {t('recalculateVectors')}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/folder/index.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo } from 'react'\nimport { Loader2, Sparkles } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport useArticleStore from '@/stores/article'\nimport { useSkillsStore } from '@/stores/skills'\nimport { computedParentPath } from '@/lib/path'\nimport { isSkillsFolder, extractSkillIdFromPath } from '@/lib/skills/utils'\nimport { FolderStatsView } from './folder-stats'\nimport { SkillsListView } from './skills-list'\nimport { SkillDetailView } from './skill-detail'\n\ninterface FolderViewProps {\n  folderPath: string\n}\n\n// Collect all markdown files in the target folder recursively\nfunction collectFiles(tree: ReturnType<typeof useArticleStore.getState>['fileTree'], targetPath: string): string[] {\n  const files: string[] = []\n\n  // Helper to collect files from a directory and its subdirectories\n  function collectFromDirectory(item: NonNullable<typeof tree[0]>, currentPath: string) {\n    if (item.isFile && item.name.endsWith('.md')) {\n      files.push(currentPath)\n      return\n    }\n\n    if (item.isDirectory && item.children) {\n      for (const child of item.children) {\n        const childPath = currentPath ? `${currentPath}/${child.name}` : child.name\n        collectFromDirectory(child as NonNullable<typeof child>, childPath)\n      }\n    }\n  }\n\n  // Find the target folder in the tree\n  function findAndCollect(_tree: NonNullable<typeof tree>, _targetPath: string): boolean {\n    for (const item of _tree) {\n      const itemPath = computedParentPath(item)\n\n      if (item.isDirectory && itemPath === _targetPath) {\n        // Found the target folder, collect all files recursively\n        if (item.children) {\n          for (const child of item.children) {\n            const childPath = `${targetPath}/${child.name}`\n            collectFromDirectory(child as NonNullable<typeof child>, childPath)\n          }\n        }\n        return true\n      }\n\n      // Search in subdirectories\n      if (item.children && findAndCollect(item.children as NonNullable<typeof item.children>, _targetPath)) {\n        return true\n      }\n    }\n    return false\n  }\n\n  if (tree) {\n    findAndCollect(tree, targetPath)\n  }\n  return files\n}\n\nexport function FolderView({ folderPath }: FolderViewProps) {\n  const t = useTranslations('article.file.folderView')\n  const { fileTree } = useArticleStore()\n  const { getSkillsByScope, initSkills, initialized: skillsStoreInitialized } = useSkillsStore()\n\n  // 检查是否是 Skills 文件夹\n  const isSkillsView = isSkillsFolder(folderPath.split('/').pop() || '')\n\n  // 检查是否是 Skill 子文件夹（单个 skill）\n  const skillId = extractSkillIdFromPath(folderPath)\n  const isSkillDetailView = skillId !== null\n\n  // 初始化 Skills（如果是 Skills 相关视图）\n  useEffect(() => {\n    if ((isSkillsView || isSkillDetailView) && !skillsStoreInitialized) {\n      initSkills()\n    }\n  }, [isSkillsView, isSkillDetailView, skillsStoreInitialized, initSkills])\n\n  // Get all files in the current folder (recursively)\n  const folderFiles = useMemo(() => collectFiles(fileTree, folderPath), [fileTree, folderPath])\n\n  // If it's a Skills folder, show Skills view\n  if (isSkillsView) {\n    // If skills not initialized yet, show loading state\n    if (!skillsStoreInitialized) {\n      return (\n        <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background\">\n          <Loader2 className=\"w-8 h-8 animate-spin text-muted-foreground\" />\n          <p className=\"text-sm text-muted-foreground mt-4\">{t('loadingSkills')}</p>\n        </div>\n      )\n    }\n\n    const globalSkills = getSkillsByScope('global')\n    const projectSkills = getSkillsByScope('project')\n    const allSkills = [...globalSkills, ...projectSkills].map(s => s.metadata)\n    return <SkillsListView skills={allSkills} />\n  }\n\n  // If it's a Skill subfolder, show Skill detail view\n  if (isSkillDetailView) {\n    // If skills not initialized yet, show loading state\n    if (!skillsStoreInitialized) {\n      return (\n        <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background\">\n          <Loader2 className=\"w-8 h-8 animate-spin text-muted-foreground\" />\n          <p className=\"text-sm text-muted-foreground mt-4\">{t('loadingSkill')}</p>\n        </div>\n      )\n    }\n\n    // Get all skills and find matching skill\n    const globalSkills = getSkillsByScope('global')\n    const projectSkills = getSkillsByScope('project')\n    const allSkills = [...globalSkills, ...projectSkills]\n\n    const skillContent = allSkills.find(s => s.metadata.id === skillId)\n\n    if (!skillContent) {\n      return (\n        <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background\">\n          <Sparkles className=\"w-16 h-16 text-muted-foreground\" />\n          <h2 className=\"text-2xl font-semibold tracking-tight mt-4\">{t('skillNotFound')}</h2>\n          <p className=\"text-muted-foreground text-sm mt-2\">\n            {t('skillNotFoundDesc', { id: skillId || '' })}\n          </p>\n        </div>\n      )\n    }\n\n    return <SkillDetailView skillContent={skillContent} />\n  }\n\n  // 普通文件夹视图\n  return <FolderStatsView folderPath={folderPath} folderFiles={folderFiles} />\n}\n"
  },
  {
    "path": "src/app/core/main/editor/folder/skill-detail.tsx",
    "content": "'use client'\n\nimport { Sparkles } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { SkillContent } from '@/lib/skills/types'\n\ninterface SkillDetailViewProps {\n  skillContent: SkillContent\n}\n\nexport function SkillDetailView({ skillContent }: SkillDetailViewProps) {\n  const t = useTranslations('article.file.folderView')\n  const { metadata, instructions, scripts, references, assets } = skillContent\n\n  return (\n    <div className=\"flex-1 h-full flex flex-col items-center justify-center bg-background gap-6 p-8 overflow-y-auto\">\n      {/* Skill Icon and Name */}\n      <div className=\"flex flex-col items-center gap-3\">\n        <Sparkles className=\"w-20 h-20 text-primary\" />\n        <h2 className=\"text-2xl font-semibold tracking-tight\">{metadata.name}</h2>\n        <p className=\"text-sm text-muted-foreground max-w-md text-center\">\n          {metadata.description}\n        </p>\n      </div>\n\n      {/* Skill Details */}\n      <div className=\"flex flex-col gap-4 w-full max-w-2xl\">\n        {/* 指令 */}\n        <div className=\"border rounded-lg p-4 space-y-3\">\n          <h3 className=\"font-semibold text-sm\">{t('instructions')}</h3>\n          <div className=\"text-sm whitespace-pre-wrap bg-muted/50 p-3 rounded max-h-60 overflow-y-auto\">\n            {instructions}\n          </div>\n        </div>\n\n        {/* Scripts */}\n        {scripts.length > 0 && (\n          <div className=\"border rounded-lg p-4 space-y-3\">\n            <h3 className=\"font-semibold text-sm\">{t('scripts')}</h3>\n            <div className=\"text-sm space-y-2\">\n              {scripts.map((script) => (\n                <div key={script.name} className=\"bg-muted/50 p-2 rounded\">\n                  <span className=\"font-medium\">{script.name}</span>\n                  {script.description && <span className=\"text-muted-foreground ml-2\">: {script.description}</span>}\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n        {/* References */}\n        {references.length > 0 && (\n          <div className=\"border rounded-lg p-4 space-y-3\">\n            <h3 className=\"font-semibold text-sm\">{t('references')}</h3>\n            <div className=\"text-sm space-y-2\">\n              {references.map((ref) => (\n                <div key={ref.name} className=\"bg-muted/50 p-2 rounded\">\n                  <span className=\"font-medium\">{ref.name}</span>\n                  {ref.description && <span className=\"text-muted-foreground ml-2\">: {ref.description}</span>}\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n\n        {/* Assets */}\n        {assets.length > 0 && (\n          <div className=\"border rounded-lg p-4 space-y-3\">\n            <h3 className=\"font-semibold text-sm\">{t('assets')}</h3>\n            <div className=\"text-sm space-y-2\">\n              {assets.map((asset) => (\n                <div key={asset.name} className=\"bg-muted/50 p-2 rounded\">\n                  <span className=\"font-medium\">{asset.name}</span>\n                  {asset.description && <span className=\"text-muted-foreground ml-2\">: {asset.description}</span>}\n                </div>\n              ))}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/folder/skills-list.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Sparkles } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { SkillMetadata } from '@/lib/skills/types'\n\ninterface SkillsListViewProps {\n  skills: SkillMetadata[]\n}\n\nexport function SkillsListView({ skills }: SkillsListViewProps) {\n  const t = useTranslations('article.file.folderView')\n\n  // 按 scope 分组\n  const globalSkills = skills.filter(s => s.scope === 'global')\n  const projectSkills = skills.filter(s => s.scope === 'project')\n\n  // 跟踪每个技能的展开状态\n  const [expandedSkills, setExpandedSkills] = useState<Set<string>>(new Set())\n\n  const toggleExpanded = (skillId: string) => {\n    setExpandedSkills(prev => {\n      const next = new Set(prev)\n      if (next.has(skillId)) {\n        next.delete(skillId)\n      } else {\n        next.add(skillId)\n      }\n      return next\n    })\n  }\n\n  return (\n    <div className=\"flex-1 h-full flex flex-col items-center bg-background gap-6 p-8 overflow-y-auto\">\n      {/* Skills Icon and Name */}\n      <div className=\"flex flex-col items-center gap-3 shrink-0\">\n        <Sparkles className=\"w-20 h-20 text-primary\" />\n        <h2 className=\"text-2xl font-semibold tracking-tight\">{t('skills')} ({skills.length})</h2>\n      </div>\n\n      {/* Skills 列表 */}\n      {skills.length === 0 ? null : (\n        <div className=\"flex flex-col gap-4 w-full max-w-2xl shrink-0\">\n          {/* 全局 Skills */}\n          {globalSkills.length > 0 && (\n            <div className=\"space-y-2\">\n              <h3 className=\"text-sm font-medium text-muted-foreground px-1\">{t('globalSkills')}</h3>\n              <div className=\"space-y-2\">\n                {globalSkills.map((skill) => (\n                  <div\n                    key={skill.id}\n                    className=\"p-4 border rounded-lg hover:bg-accent/5 transition-colors bg-blue-50/50 dark:bg-blue-950/20 cursor-pointer\"\n                    onClick={() => toggleExpanded(skill.id)}\n                  >\n                    <div className=\"flex items-start gap-4\">\n                      <Sparkles className=\"size-5 text-primary mt-1\" />\n                      <div className=\"flex-1\">\n                        <h3 className=\"font-semibold mb-1\">{skill.name}</h3>\n                        <p className=\"text-sm text-muted-foreground cursor-pointer\">\n                          {expandedSkills.has(skill.id) ? skill.description : (\n                            <span className=\"line-clamp-1\">{skill.description}</span>\n                          )}\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* 工作区 Skills */}\n          {projectSkills.length > 0 && (\n            <div className=\"space-y-2\">\n              <h3 className=\"text-sm font-medium text-muted-foreground px-1\">{t('workspaceSkills')}</h3>\n              <div className=\"space-y-2\">\n                {projectSkills.map((skill) => (\n                  <div\n                    key={skill.id}\n                    className=\"p-4 border rounded-lg hover:bg-accent/5 transition-colors bg-purple-50/50 dark:bg-purple-950/20 cursor-pointer\"\n                    onClick={() => toggleExpanded(skill.id)}\n                  >\n                    <div className=\"flex items-start gap-4\">\n                      <Sparkles className=\"size-5 text-primary mt-1\" />\n                      <div className=\"flex-1\">\n                        <h3 className=\"font-semibold mb-1\">{skill.name}</h3>\n                        <p className=\"text-sm text-muted-foreground cursor-pointer\">\n                          {expandedSkills.has(skill.id) ? skill.description : (\n                            <span className=\"line-clamp-1\">{skill.description}</span>\n                          )}\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                ))}\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/image/image-editor.css",
    "content": ".image-editor {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  width: 100%;\n  background: var(--background);\n}\n\n.image-editor-toolbar {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.5rem;\n  border-bottom: 1px solid var(--border);\n  background: var(--background);\n}\n\n.image-editor-content {\n  flex: 1;\n  overflow: hidden;\n  position: relative;\n}\n"
  },
  {
    "path": "src/app/core/main/editor/image/image-editor.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useRef } from 'react'\nimport { Cropper, CropperRef } from 'react-advanced-cropper'\nimport 'react-advanced-cropper/dist/style.css'\nimport { Button } from '@/components/ui/button'\nimport { \n  RotateCw, \n  FlipHorizontal, \n  FlipVertical, \n  ZoomIn, \n  ZoomOut,\n  Crop,\n  Save,\n  Undo\n} from 'lucide-react'\nimport { getWorkspacePath, getFilePathOptions } from '@/lib/workspace'\nimport { readFile, writeFile } from '@tauri-apps/plugin-fs'\nimport { toast } from '@/hooks/use-toast'\nimport useArticleStore from '@/stores/article'\nimport { Separator } from '@/components/ui/separator'\nimport { Toggle } from '@/components/ui/toggle'\nimport { ImageFooter } from './image-footer'\nimport { TooltipButton } from '@/components/tooltip-button'\nimport NextImage from 'next/image'\n\ninterface ImageEditorProps {\n  filePath: string\n}\n\nexport function ImageEditor({ filePath }: ImageEditorProps) {\n  const cropperRef = useRef<CropperRef>(null)\n  const [imageSrc, setImageSrc] = useState<string>('')\n  const [loading, setLoading] = useState(true)\n  const [hasChanges, setHasChanges] = useState(false)\n  const [originalImageData, setOriginalImageData] = useState<Uint8Array | null>(null)\n  const [cropMode, setCropMode] = useState(false)\n  const [imageWidth, setImageWidth] = useState<number>(0)\n  const [imageHeight, setImageHeight] = useState<number>(0)\n  const { loadFileTree } = useArticleStore()\n\n  useEffect(() => {\n    loadImage()\n  }, [filePath])\n\n  async function loadImage() {\n    if (!filePath) return\n    \n    try {\n      setLoading(true)\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(filePath)\n      \n      let imageData: Uint8Array\n      if (workspace.isCustom) {\n        imageData = await readFile(pathOptions.path)\n      } else {\n        imageData = await readFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n      \n      setOriginalImageData(imageData)\n      \n      const blob = new Blob([imageData as unknown as BlobPart])\n      const url = URL.createObjectURL(blob)\n      setImageSrc(url)\n      setHasChanges(false)\n      \n      // 加载图片尺寸\n      const img = new Image()\n      img.onload = () => {\n        setImageWidth(img.naturalWidth)\n        setImageHeight(img.naturalHeight)\n      }\n      img.src = url\n    } catch (error) {\n      console.error('Failed to load image:', error)\n      toast({\n        title: '加载图片失败',\n        description: String(error),\n        variant: 'destructive'\n      })\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  const applyImageTransform = async (transformFn: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, img: HTMLImageElement) => void) => {\n    try {\n      const img = new Image()\n      img.crossOrigin = 'anonymous'\n      img.src = imageSrc\n      \n      await new Promise((resolve, reject) => {\n        img.onload = resolve\n        img.onerror = reject\n      })\n\n      const canvas = document.createElement('canvas')\n      const ctx = canvas.getContext('2d')\n      if (!ctx) return\n\n      transformFn(canvas, ctx, img)\n\n      const blob = await new Promise<Blob>((resolve) => {\n        canvas.toBlob((b) => {\n          if (b) resolve(b)\n        }, 'image/png')\n      })\n\n      const url = URL.createObjectURL(blob)\n      setImageSrc(url)\n      setHasChanges(true)\n      \n      // 更新图片尺寸\n      setImageWidth(canvas.width)\n      setImageHeight(canvas.height)\n    } catch (error) {\n      console.error('Failed to transform image:', error)\n    }\n  }\n\n  const handleRotate = () => {\n    applyImageTransform((canvas, ctx, img) => {\n      canvas.width = img.height\n      canvas.height = img.width\n      ctx.translate(canvas.width / 2, canvas.height / 2)\n      ctx.rotate(90 * Math.PI / 180)\n      ctx.drawImage(img, -img.width / 2, -img.height / 2)\n    })\n  }\n\n  const handleFlipHorizontal = () => {\n    applyImageTransform((canvas, ctx, img) => {\n      canvas.width = img.width\n      canvas.height = img.height\n      ctx.translate(canvas.width, 0)\n      ctx.scale(-1, 1)\n      ctx.drawImage(img, 0, 0)\n    })\n  }\n\n  const handleFlipVertical = () => {\n    applyImageTransform((canvas, ctx, img) => {\n      canvas.width = img.width\n      canvas.height = img.height\n      ctx.translate(0, canvas.height)\n      ctx.scale(1, -1)\n      ctx.drawImage(img, 0, 0)\n    })\n  }\n\n  const handleZoomIn = () => {\n    if (cropperRef.current) {\n      cropperRef.current.zoomImage(1.2)\n    }\n  }\n\n  const handleZoomOut = () => {\n    if (cropperRef.current) {\n      cropperRef.current.zoomImage(0.8)\n    }\n  }\n\n  const handleReset = () => {\n    if (originalImageData) {\n      const blob = new Blob([originalImageData as unknown as BlobPart])\n      const url = URL.createObjectURL(blob)\n      setImageSrc(url)\n      setHasChanges(false)\n      setCropMode(false)\n    }\n  }\n\n  const handleSave = async () => {\n    try {\n      let blob: Blob\n\n      if (cropperRef.current) {\n        // 如果在裁切模式，从 Cropper 获取图片\n        const canvas = cropperRef.current.getCanvas()\n        if (!canvas) return\n\n        blob = await new Promise<Blob>((resolve) => {\n          canvas.toBlob((b) => {\n            if (b) resolve(b)\n          }, 'image/png')\n        })\n      } else {\n        // 非裁切模式，直接从 imageSrc 获取图片数据\n        const response = await fetch(imageSrc)\n        blob = await response.blob()\n      }\n\n      const arrayBuffer = await blob.arrayBuffer()\n      const uint8Array = new Uint8Array(arrayBuffer)\n\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(filePath)\n\n      if (workspace.isCustom) {\n        await writeFile(pathOptions.path, uint8Array)\n      } else {\n        await writeFile(pathOptions.path, uint8Array, { baseDir: pathOptions.baseDir })\n      }\n\n      setOriginalImageData(uint8Array)\n      setHasChanges(false)\n      setCropMode(false)\n      \n      await loadFileTree()\n\n      toast({\n        title: '保存成功',\n        description: '图片已保存'\n      })\n    } catch (error) {\n      console.error('Failed to save image:', error)\n      toast({\n        title: '保存失败',\n        description: String(error),\n        variant: 'destructive'\n      })\n    }\n  }\n\n  const handleCropComplete = async () => {\n    if (!cropMode || !cropperRef.current) return\n    \n    try {\n      // 获取裁切后的图片\n      const canvas = cropperRef.current.getCanvas()\n      if (!canvas) return\n\n      const blob = await new Promise<Blob>((resolve) => {\n        canvas.toBlob((blob) => {\n          if (blob) resolve(blob)\n        }, 'image/png')\n      })\n\n      // 更新图片显示\n      const url = URL.createObjectURL(blob)\n      setImageSrc(url)\n      \n      // 更新图片尺寸\n      const img = new Image()\n      img.onload = () => {\n        setImageWidth(img.naturalWidth)\n        setImageHeight(img.naturalHeight)\n      }\n      img.src = url\n      \n      setHasChanges(true)\n      setCropMode(false)\n    } catch (error) {\n      console.error('Failed to crop image:', error)\n    }\n  }\n\n  if (loading) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center h-full bg-background\">\n        <p className=\"text-muted-foreground\">加载中...</p>\n      </div>\n    )\n  }\n\n  if (!imageSrc) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center h-full bg-background\">\n        <p className=\"text-muted-foreground\">无法加载图片</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex-1 flex flex-col h-full bg-background\">\n      {/* Toolbar */}\n      <div className=\"h-12 flex items-center gap-2 px-2 border-b bg-background\">\n        <Toggle\n          pressed={cropMode}\n          onPressedChange={setCropMode}\n          aria-label=\"裁切模式\"\n          size=\"sm\"\n        >\n          <Crop className=\"h-4 w-4\" />\n        </Toggle>\n        \n        <Separator orientation=\"vertical\" className=\"h-6\" />\n        \n        <TooltipButton\n          icon={<RotateCw className=\"h-4 w-4\" />}\n          tooltipText=\"旋转\"\n          onClick={handleRotate}\n          size=\"sm\"\n          side=\"bottom\"\n        />\n        \n        <TooltipButton\n          icon={<FlipHorizontal className=\"h-4 w-4\" />}\n          tooltipText=\"水平翻转\"\n          onClick={handleFlipHorizontal}\n          size=\"sm\"\n          side=\"bottom\"\n        />\n        \n        <TooltipButton\n          icon={<FlipVertical className=\"h-4 w-4\" />}\n          tooltipText=\"垂直翻转\"\n          onClick={handleFlipVertical}\n          size=\"sm\"\n          side=\"bottom\"\n        />\n        \n        <div className=\"flex-1\" />\n        \n        <TooltipButton\n          icon={<ZoomIn className=\"h-4 w-4\" />}\n          tooltipText=\"放大\"\n          onClick={handleZoomIn}\n          size=\"sm\"\n          side=\"bottom\"\n        />\n        \n        <TooltipButton\n          icon={<ZoomOut className=\"h-4 w-4\" />}\n          tooltipText=\"缩小\"\n          onClick={handleZoomOut}\n          size=\"sm\"\n          side=\"bottom\"\n        />\n        \n        {hasChanges && (\n          <>\n            <Separator orientation=\"vertical\" className=\"h-6\" />\n            \n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={handleReset}\n            >\n              <Undo className=\"h-4 w-4 mr-1\" />\n              重置\n            </Button>\n            \n            <Button\n              variant=\"default\"\n              size=\"sm\"\n              onClick={handleSave}\n            >\n              <Save className=\"h-4 w-4 mr-1\" />\n              保存\n            </Button>\n          </>\n        )}\n      </div>\n\n      {/* Image Display / Cropper */}\n      <div className=\"flex-1 overflow-auto relative bg-background flex items-center justify-center\">\n        {cropMode ? (\n          <div \n            className=\"w-full h-full\"\n            onDoubleClick={handleCropComplete}\n          >\n            <Cropper\n              ref={cropperRef}\n              src={imageSrc}\n              className=\"h-full w-full\"\n              stencilProps={{\n                movable: true,\n                resizable: true,\n                lines: true,\n                handlers: true,\n              }}\n              onChange={() => {\n                setHasChanges(true)\n              }}\n            />\n          </div>\n        ) : (\n          <NextImage \n            src={imageSrc} \n            alt=\"Preview\"\n            width={imageWidth}\n            height={imageHeight}\n            style={{\n              maxWidth: '100%',\n              maxHeight: '100%',\n              objectFit: 'contain',\n              imageRendering: 'auto'\n            }}\n            unoptimized\n          />\n        )}\n      </div>\n\n      {/* Footer */}\n      <ImageFooter \n        filePath={filePath} \n        imageWidth={imageWidth} \n        imageHeight={imageHeight} \n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/image/image-footer.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { getWorkspacePath, getFilePathOptions } from '@/lib/workspace'\nimport { stat } from '@tauri-apps/plugin-fs'\n\ninterface ImageFooterProps {\n  filePath: string\n  imageWidth?: number\n  imageHeight?: number\n}\n\nexport function ImageFooter({ filePath, imageWidth, imageHeight }: ImageFooterProps) {\n  const [fileSize, setFileSize] = useState<string>('')\n  const [fileName, setFileName] = useState<string>('')\n\n  useEffect(() => {\n    loadFileInfo()\n  }, [filePath])\n\n  async function loadFileInfo() {\n    if (!filePath) return\n\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(filePath)\n      \n      let fileStat\n      if (workspace.isCustom) {\n        fileStat = await stat(pathOptions.path)\n      } else {\n        fileStat = await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n\n      // 格式化文件大小\n      const sizeInBytes = fileStat.size\n      let formattedSize = ''\n      if (sizeInBytes < 1024) {\n        formattedSize = `${sizeInBytes} B`\n      } else if (sizeInBytes < 1024 * 1024) {\n        formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`\n      } else {\n        formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`\n      }\n      \n      setFileSize(formattedSize)\n      setFileName(filePath.split('/').pop() || '')\n    } catch (error) {\n      console.error('Failed to load file info:', error)\n    }\n  }\n\n  return (\n    <div className=\"h-6 w-full px-2 border-t shadow-sm items-center flex justify-between overflow-hidden bg-background\">\n      <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n        <span className=\"truncate max-w-md\" title={fileName}>{fileName}</span>\n      </div>\n      <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n        {fileSize && <span>{fileSize}</span>}\n        {fileSize && imageWidth && imageHeight && <span>•</span>}\n        {imageWidth && imageHeight && (\n          <span>{imageWidth} × {imageHeight}</span>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/ai-completion.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/core'\nimport { ReactRenderer } from '@tiptap/react'\nimport tippy from 'tippy.js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Sparkles, X, ChevronRight } from 'lucide-react'\n\ninterface AICompletionProps {\n  editor: Editor\n  isEnabled: boolean\n  onComplete: (prompt: string) => Promise<string>\n}\n\ninterface SuggestionItem {\n  text: string\n  icon?: React.ReactNode\n}\n\n// Used by ReactRenderer - keep for Tippy.js integration\nexport function AICompletionPopup({ items, onSelect, onDismiss }: {\n  items: SuggestionItem[]\n  onSelect: (item: SuggestionItem) => void\n  onDismiss: () => void\n}) {\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const listRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    setSelectedIndex(0)\n  }, [items])\n\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      setSelectedIndex(i => Math.min(i + 1, items.length - 1))\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      setSelectedIndex(i => Math.max(i - 1, 0))\n    } else if (e.key === 'Enter') {\n      e.preventDefault()\n      if (items[selectedIndex]) {\n        onSelect(items[selectedIndex])\n      }\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      onDismiss()\n    }\n  }, [items, selectedIndex, onSelect, onDismiss])\n\n  useEffect(() => {\n    listRef.current?.focus()\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [handleKeyDown])\n\n  if (items.length === 0) return null\n\n  return (\n    <div\n      ref={listRef}\n      className=\"ai-completion-dropdown min-w-[280px] bg-[hsl(var(--background))] border border-[hsl(var(--border))] rounded-lg shadow-lg overflow-hidden\"\n      tabIndex={-1}\n    >\n      <div className=\"flex items-center gap-2 px-3 py-2 bg-[hsl(var(--muted))] border-b border-[hsl(var(--border))]\">\n        <Sparkles size={14} className=\"text-[hsl(var(--primary))]\" />\n        <span className=\"text-xs font-medium text-[hsl(var(--muted-foreground))]\">AI 建议</span>\n      </div>\n      <div className=\"max-h-[200px] overflow-y-auto\">\n        {items.map((item, index) => (\n          <button\n            key={index}\n            onClick={() => onSelect(item)}\n            className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-[hsl(var(--muted))] transition-colors\n              ${index === selectedIndex ? 'bg-[hsl(var(--muted))]' : ''}\n            `}\n          >\n            {item.icon || <ChevronRight size={14} />}\n            <span className=\"flex-1 truncate\">{item.text}</span>\n            {index === selectedIndex && (\n              <span className=\"text-xs text-[hsl(var(--muted-foreground))]\">↵</span>\n            )}\n          </button>\n        ))}\n      </div>\n      <div className=\"flex items-center justify-between px-3 py-1.5 bg-[hsl(var(--muted))] border-t border-[hsl(var(--border))]\">\n        <span className=\"text-xs text-[hsl(var(--muted-foreground))]\">↑↓ 选择</span>\n        <button onClick={onDismiss} className=\"text-xs hover:text-[hsl(var(--foreground))]\">\n          <X size={12} />\n        </button>\n      </div>\n    </div>\n  )\n}\n\nexport function useAIAutocomplete({ editor, isEnabled, onComplete }: AICompletionProps) {\n  const popupRef = useRef<any>(null)\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [_suggestions, setSuggestions] = useState<SuggestionItem[]>([])\n\n  const showPopup = useCallback((items: SuggestionItem[], clientRect: DOMRect) => {\n    if (popupRef.current) {\n      popupRef.current.destroy()\n    }\n\n    const popup = document.createElement('div')\n    document.body.appendChild(popup)\n\n    // ReactRenderer references the component by name for Tippy.js integration\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const reactRenderer: any = new ReactRenderer(AICompletionPopup, {\n      props: {\n        items,\n        onSelect: (item: SuggestionItem) => {\n          insertCompletion(item.text)\n          hidePopup()\n        },\n        onDismiss: hidePopup,\n      },\n      editor: editor,\n    })\n\n    popupRef.current = tippy('body', {\n      getReferenceClientRect: () => clientRect,\n      appendTo: () => document.body,\n      content: popup,\n      showOnCreate: true,\n      interactive: true,\n      trigger: 'manual',\n      placement: 'bottom-start',\n    })\n\n    // Mount React component\n    ;(reactRenderer as any).mount?.(popup)\n  }, [editor])\n\n  const hidePopup = useCallback(() => {\n    if (popupRef.current) {\n      popupRef.current.destroy()\n      popupRef.current = null\n    }\n    setSuggestions([])\n  }, [])\n\n  const insertCompletion = useCallback((text: string) => {\n    editor.commands.insertContent(text, { contentType: 'markdown' })\n  }, [editor])\n\n  // Trigger AI completion manually (e.g., via keyboard shortcut)\n  const triggerCompletion = useCallback(async () => {\n    if (!isEnabled) return\n\n    const { from } = editor.state.selection\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const textBefore = (editor.state.doc as any).textBefore(from, 50)\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const lineBefore = (editor.state.doc as any).textAfter(from, '\\n')\n\n    // Show loading state\n    const rect = editor.view.coordsAtPos(from) as DOMRect\n    showPopup([\n      { text: '正在思考...', icon: <Sparkles size={14} className=\"animate-pulse\" /> }\n    ], rect)\n\n    try {\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      const result = await onComplete((textBefore || '') + (lineBefore || ''))\n\n      // Parse suggestions from result\n      const suggestions = result\n        .split('\\n')\n        .filter(line => line.trim())\n        .map(line => ({ text: line.trim().replace(/^[-*•]\\s*/, '') }))\n\n      if (suggestions.length > 0) {\n        const rect = editor.view.coordsAtPos(from) as DOMRect\n        showPopup(suggestions, rect)\n      } else {\n        // Insert result directly if no suggestions\n        insertCompletion(result)\n        hidePopup()\n      }\n    } catch (error) {\n      console.error('AI completion error:', error)\n      hidePopup()\n    }\n  }, [editor, isEnabled, onComplete, showPopup, insertCompletion, hidePopup])\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      hidePopup()\n    }\n  }, [hidePopup])\n\n  return {\n    triggerCompletion,\n    hidePopup,\n  }\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/ai-suggestion-floating.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { Check, X, Sparkles, Loader2, CircleX } from 'lucide-react'\nimport { useState, useEffect, useCallback, useRef } from 'react'\nimport { useTranslations } from 'next-intl'\nimport emitter from '@/lib/emitter'\n\ninterface AISuggestionFloatingProps {\n  editor: Editor\n}\n\ninterface SuggestionData {\n  originalText: string\n  suggestedText: string\n  type: string\n  generatedRange?: { from: number; to: number }\n}\n\ninterface PositionData {\n  position: { top: number; left: number; right: number; bottom: number }\n}\n\nexport function AISuggestionFloating({ editor }: AISuggestionFloatingProps) {\n  const t = useTranslations('editor')\n  const [suggestion, setSuggestion] = useState<SuggestionData | null>(null)\n  const [position, setPosition] = useState({ top: 0, left: 0 })\n  const [isVisible, setIsVisible] = useState(false)\n  const [isStreaming, setIsStreaming] = useState(false)\n  const [abortController, setAbortController] = useState<AbortController | null>(null)\n  const buttonRef = useRef<HTMLDivElement>(null)\n  const latestSuggestionRef = useRef<SuggestionData | null>(null)\n\n  // Keep the ref in sync\n  useEffect(() => {\n    latestSuggestionRef.current = suggestion\n  }, [suggestion])\n\n  // 清理\n  useEffect(() => {\n    return () => {\n      if (abortController) {\n        abortController.abort()\n      }\n    }\n  }, [abortController])\n\n  // Calculate position helper\n  const calculatePosition = useCallback((dataPosition: { top: number; left: number; right: number; bottom: number }) => {\n    const editorElement = document.querySelector('.ProseMirror')\n    const scrollContainer = editorElement?.parentElement\n\n    if (!scrollContainer) {\n      return { top: dataPosition.bottom - 10, left: dataPosition.left }\n    }\n\n    const containerBounds = scrollContainer.getBoundingClientRect()\n    const relativeTop = dataPosition.bottom - containerBounds.top + scrollContainer.scrollTop - 10\n    const relativeLeft = dataPosition.left - containerBounds.left + scrollContainer.scrollLeft\n\n    // 边界检测：left 在 [0, 容器宽度 - 菜单宽度] 范围内\n    const currentMenuWidth = buttonRef.current?.offsetWidth || 180\n    const maxLeft = Math.max(0, containerBounds.width - currentMenuWidth)\n    const left = Math.min(relativeLeft, maxLeft)\n\n    return { top: relativeTop, left }\n  }, [])\n\n  // Listen for AI suggestion events\n  useEffect(() => {\n    if (!editor) return\n\n    // Show suggestion immediately with streaming state\n    const handleStartStreaming = (data: {\n      originalText: string\n      type: string\n      position: { top: number; left: number; right: number; bottom: number }\n      controller?: AbortController\n    }) => {\n      setSuggestion({\n        originalText: data.originalText,\n        suggestedText: '',\n        type: data.type,\n      })\n\n      const pos = calculatePosition(data.position)\n      setPosition(pos)\n      setIsVisible(true)\n      setIsStreaming(true)\n\n      if (data.controller) {\n        setAbortController(data.controller)\n      }\n    }\n\n    // Update streaming content and position as it arrives\n    const handleUpdateContent = (data: {\n      suggestedText: string\n      position: { top: number; left: number; right: number; bottom: number }\n    }) => {\n      setSuggestion(prev => prev ? {\n        ...prev,\n        suggestedText: data.suggestedText,\n      } : null)\n\n      const pos = calculatePosition(data.position)\n      setPosition(pos)\n    }\n\n    // Streaming completed, show accept/reject buttons\n    const handleStreamingComplete = (data?: SuggestionData & PositionData & { generatedRange?: { from: number; to: number } }) => {\n      if (data) {\n        setSuggestion({\n          originalText: data.originalText,\n          suggestedText: data.suggestedText,\n          type: data.type,\n          generatedRange: data.generatedRange,\n        })\n\n        const pos = calculatePosition(data.position)\n        setPosition(pos)\n        setIsVisible(true)\n      }\n      setIsStreaming(false)\n      setAbortController(null)\n    }\n\n    // 终止生成\n    const handleAbortStreaming = () => {\n      if (abortController) {\n        abortController.abort()\n      }\n      setIsStreaming(false)\n      setAbortController(null)\n\n      // 恢复原始文本\n      const current = latestSuggestionRef.current\n      if (current) {\n        editor.chain()\n          .focus()\n          .deleteSelection()\n          .insertContent(current.originalText)\n          .run()\n      }\n\n      setIsVisible(false)\n      setSuggestion(null)\n    }\n\n    // Show suggestion after streaming completes\n    const handleShowSuggestion = (data: SuggestionData & PositionData & { generatedRange?: { from: number; to: number } }) => {\n      setSuggestion({\n        originalText: data.originalText,\n        suggestedText: data.suggestedText,\n        type: data.type,\n        generatedRange: data.generatedRange,\n      })\n\n      const pos = calculatePosition(data.position)\n      setPosition(pos)\n      setIsVisible(true)\n      setIsStreaming(false)\n    }\n\n    emitter.on('start-ai-streaming', handleStartStreaming)\n    emitter.on('update-ai-streaming-content', handleUpdateContent)\n    emitter.on('ai-streaming-complete', handleStreamingComplete)\n    emitter.on('show-ai-suggestion', handleShowSuggestion)\n    emitter.on('abort-ai-streaming', handleAbortStreaming)\n\n    return () => {\n      emitter.off('start-ai-streaming', handleStartStreaming)\n      emitter.off('update-ai-streaming-content', handleUpdateContent)\n      emitter.off('ai-streaming-complete', handleStreamingComplete)\n      emitter.off('show-ai-suggestion', handleShowSuggestion)\n      emitter.off('abort-ai-streaming', handleAbortStreaming)\n    }\n  }, [editor, abortController, calculatePosition])\n\n  const handleAccept = useCallback(() => {\n    // Accept: keep the current AI-generated text (do nothing)\n    setIsVisible(false)\n    setSuggestion(null)\n  }, [])\n\n  const handleReject = useCallback(() => {\n    const current = latestSuggestionRef.current\n    if (!current) return\n\n    // Reject: delete generated text and insert original\n    if (current.generatedRange) {\n      // Delete the generated text and insert original\n      editor.chain()\n        .focus()\n        .deleteRange(current.generatedRange)\n        .insertContent(current.originalText)\n        .run()\n    } else {\n      // Fallback: try to delete selection and insert original\n      editor.chain()\n        .focus()\n        .deleteSelection()\n        .insertContent(current.originalText)\n        .run()\n    }\n\n    setIsVisible(false)\n    setSuggestion(null)\n  }, [editor])\n\n  const handleAbort = useCallback(() => {\n    emitter.emit('abort-ai-streaming')\n  }, [])\n\n  // Don't render if not visible\n  if (!isVisible) return null\n\n  const typeLabels: Record<string, string> = {\n    polish: t('bubbleMenu.polish'),\n    concise: t('bubbleMenu.concise'),\n    expand: t('bubbleMenu.expand'),\n  }\n\n  return (\n    <div\n      ref={buttonRef}\n      className=\"absolute z-50 flex items-center gap-1 px-2 py-1.5 bg-primary text-primary-foreground rounded-lg shadow-lg animate-in fade-in slide-in-from-bottom-2 duration-150\"\n      style={{\n        top: position.top,\n        left: position.left,\n      }}\n    >\n      {isStreaming ? (\n        <>\n          <Loader2 className=\"w-3.5 h-3.5 animate-spin\" />\n          <span className=\"text-xs font-medium\">{t('aiSuggestion.generating')}</span>\n          <div className=\"w-px h-4 bg-primary/30 mx-1\" />\n          <button\n            onClick={handleAbort}\n            className=\"p-1 hover:bg-primary/80 rounded transition-colors\"\n            title={t('aiSuggestion.abort')}\n          >\n            <CircleX className=\"w-3.5 h-3.5\" />\n          </button>\n        </>\n      ) : (\n        <>\n          <Sparkles className=\"w-3.5 h-3.5\" />\n          <span className=\"text-xs font-medium\">{suggestion && typeLabels[suggestion.type] ? typeLabels[suggestion.type] : t('bubbleMenu.ai')}</span>\n          <div className=\"w-px h-4 bg-primary/30 mx-1\" />\n          <button\n            onClick={handleAccept}\n            className=\"p-1 hover:bg-primary/80 rounded transition-colors\"\n            title={t('aiSuggestion.accept')}\n          >\n            <Check className=\"w-3.5 h-3.5\" />\n          </button>\n          <button\n            onClick={handleReject}\n            className=\"p-1 hover:bg-primary/80 rounded transition-colors\"\n            title={t('aiSuggestion.reject')}\n          >\n            <X className=\"w-3.5 h-3.5\" />\n          </button>\n        </>\n      )}\n    </div>\n  )\n}\n\nexport default AISuggestionFloating\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/ai-suggestion.ts",
    "content": "import { Mark, mergeAttributes } from '@tiptap/core'\n\nexport interface AISuggestionOptions {\n  HTMLAttributes: Record<string, string>\n}\n\ndeclare module '@tiptap/core' {\n  interface Commands<ReturnType> {\n    aiSuggestion: {\n      setAISuggestion: (originalText: string) => ReturnType\n      acceptAISuggestion: () => ReturnType\n      rejectAISuggestion: () => ReturnType\n    }\n  }\n}\n\nexport const AISuggestion = Mark.create<AISuggestionOptions>({\n  name: 'aiSuggestion',\n\n  addOptions() {\n    return {\n      HTMLAttributes: {},\n    }\n  },\n\n  addAttributes() {\n    return {\n      originalText: {\n        default: '',\n        parseHTML: element => element.getAttribute('data-original'),\n        renderHTML: attributes => {\n          return {\n            'data-original': attributes.originalText,\n          }\n        },\n      },\n      type: {\n        default: 'polish',\n        parseHTML: element => element.getAttribute('data-type'),\n        renderHTML: attributes => {\n          return {\n            'data-type': attributes.type,\n          }\n        },\n      },\n    }\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: 'span[data-ai-suggestion]',\n      },\n    ]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return [\n      'span',\n      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {\n        'data-ai-suggestion': '',\n        class: 'ai-suggestion',\n      }),\n      0,\n    ]\n  },\n\n  addCommands() {\n    return {\n      setAISuggestion:\n        (originalText, type = 'polish') =>\n        ({ commands }) => {\n          return commands.setMark(this.name, { originalText, type })\n        },\n      acceptAISuggestion:\n        () =>\n        ({ commands }) => {\n          return commands.unsetMark(this.name)\n        },\n      rejectAISuggestion:\n        () =>\n        () => {\n          // Get original text from the mark and restore it\n          // This is handled by the UI layer which has access to the editor state\n          return true\n        },\n    }\n  },\n})\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/bubble-menu.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport {\n  Bold,\n  Italic,\n  Strikethrough,\n  Underline,\n  Code,\n  Link,\n  Highlighter,\n  Quote,\n  List,\n  ListOrdered,\n  CheckSquare,\n  Sparkles,\n  MessageCircle,\n  Minimize2,\n  Maximize2,\n  Languages,\n  ChevronRight\n} from 'lucide-react'\nimport { useState, useCallback, useEffect, useRef } from 'react'\nimport { cn } from '@/lib/utils'\nimport { useTranslations } from 'next-intl'\nimport { fetchAiTranslate } from '@/lib/ai/translate'\nimport { toast } from '@/hooks/use-toast'\n\nconst POPULAR_LANGUAGES = [\n  { name: 'English', code: 'English', i18nKey: 'languages.English' },\n  { name: '日本語', code: 'Japanese', i18nKey: 'languages.Japanese' },\n  { name: '한국어', code: 'Korean', i18nKey: 'languages.Korean' },\n  { name: 'Français', code: 'French', i18nKey: 'languages.French' },\n  { name: 'Deutsch', code: 'German', i18nKey: 'languages.German' },\n  { name: 'Español', code: 'Spanish', i18nKey: 'languages.Spanish' },\n  { name: 'Português', code: 'Portuguese', i18nKey: 'languages.Portuguese' },\n  { name: 'Русский', code: 'Russian', i18nKey: 'languages.Russian' },\n  { name: 'العربية', code: 'Arabic', i18nKey: 'languages.Arabic' },\n]\n\ninterface BubbleMenuProps {\n  editor: Editor\n  onAIPolish?: () => void\n  onAIConcise?: () => void\n  onAIExpand?: () => void\n  onQuoteToChat?: () => void\n}\n\nexport function BubbleMenu({\n  editor,\n  onAIPolish,\n  onAIConcise,\n  onAIExpand,\n  onQuoteToChat,\n}: BubbleMenuProps) {\n  const t = useTranslations('editor')\n  const [show, setShow] = useState(false)\n  const [position, setPosition] = useState({ top: 0, left: 0 })\n  const [showAISubmenu, setShowAISubmenu] = useState(false)\n  const [showTranslateSubmenu, setShowTranslateSubmenu] = useState(false)\n  const [customTranslateLang, setCustomTranslateLang] = useState('')\n  const [linkUrl, setLinkUrl] = useState('')\n  const [showLinkInput, setShowLinkInput] = useState(false)\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [isInteractingWithMenu, setIsInteractingWithMenu] = useState(false)\n  const menuRef = useRef<HTMLDivElement>(null)\n  const aiSubmenuRef = useRef<HTMLDivElement>(null)\n  const translateSubmenuRef = useRef<HTMLDivElement>(null)\n\n  // 处理翻译\n  const handleTranslate = useCallback(async (targetLanguage: string) => {\n    const selectedText = editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)\n    if (!selectedText) {\n      toast({ title: t('translation.fail'), description: t('translation.failNoSelection'), variant: 'destructive' })\n      return\n    }\n    toast({ title: t('translation.translating'), description: t('translation.translatingTo', { language: targetLanguage }) })\n    try {\n      const result = await fetchAiTranslate(selectedText, targetLanguage)\n      if (result) {\n        editor.chain().focus().insertContent(result).run()\n        toast({ title: t('translation.success'), description: t('translation.successTo', { language: targetLanguage }) })\n      }\n    } catch (error) {\n      toast({ title: t('translation.fail'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' })\n    }\n  }, [editor, t])\n\n  const handleCustomTranslate = useCallback(async () => {\n    const targetLanguage = customTranslateLang.trim()\n    if (!targetLanguage) {\n      toast({ title: t('translation.customLanguageEmpty'), description: t('translation.customLanguageExample'), variant: 'destructive' })\n      return\n    }\n    await handleTranslate(targetLanguage)\n    setCustomTranslateLang('')\n  }, [customTranslateLang, handleTranslate, t])\n\n  // 更新定位\n  const updatePosition = useCallback(() => {\n    const { selection } = editor.state\n    const { from, to } = selection\n\n    // 检查选区是否有效（空选区、光标位置、无效位置都不显示）\n    if (from === to || from < 0 || to < 0 || from > editor.state.doc.content.size || to > editor.state.doc.content.size) {\n      setShow(false)\n      return\n    }\n\n    // 检查编辑器是否有焦点（没有焦点时不显示）\n    // 但如果选区有文本内容（from !== to），即使失去焦点也保持显示\n    // 这样可以避免点击工具栏按钮时菜单被隐藏\n    const hasSelection = from !== to\n    if (!hasSelection && !editor.view.hasFocus()) {\n      setShow(false)\n      return\n    }\n\n    // 检查是否是图片节点\n    const node = editor.state.doc.nodeAt(from)\n    if (node?.type.name === 'image') {\n      setShow(false)\n      return\n    }\n\n    // 检查是否是数学公式节点，如果是则不显示 bubble menu\n    if (node?.type.name === 'inlineMath' || node?.type.name === 'blockMath') {\n      setShow(false)\n      return\n    }\n\n    // 获取编辑器元素和滚动容器\n    const editorElement = document.querySelector('.ProseMirror')\n    const scrollContainer = editorElement?.parentElement\n    if (!editorElement || !scrollContainer) return\n\n    const containerBounds = scrollContainer.getBoundingClientRect()\n\n    // 获取选区坐标（视口坐标）\n    const coords = editor.view.coordsAtPos(from)\n\n    // 转换为滚动容器内的相对坐标\n    const relativeTop = coords.top - containerBounds.top + scrollContainer.scrollTop\n    const relativeLeft = coords.left - containerBounds.left + scrollContainer.scrollLeft\n\n    // 计算菜单位置（顶部在选区上方）\n    const top = relativeTop - 48 // 48 是大约的菜单高度 + 间距\n\n    // 边界检测：left 在 [0, 容器宽度 - 菜单宽度] 范围内\n    const currentMenuWidth = menuRef.current?.offsetWidth || 360\n    // maxLeft 不能为负数\n    const maxLeft = Math.max(0, containerBounds.width - currentMenuWidth)\n    const left = Math.min(relativeLeft, maxLeft)\n\n    // 如果上方空间不够，改为在光标下方显示\n    if (relativeTop < 48) {\n      setPosition({ top: relativeTop + 24, left })\n    } else {\n      setPosition({ top, left })\n    }\n\n    setShow(true)\n  }, [editor])\n\n  // AI子菜单边界检测\n  useEffect(() => {\n    if (!showAISubmenu || !aiSubmenuRef.current) return\n\n    const checkSubmenuBounds = () => {\n      const rect = aiSubmenuRef.current!.getBoundingClientRect()\n\n      // 直接获取最新编辑器边界\n      const editorElement = document.querySelector('.ProseMirror')\n      if (!editorElement) return\n\n      const editorBounds = editorElement.getBoundingClientRect()\n      const padding = 8\n\n      // 检测右边界 - 基于编辑器边缘\n      if (rect.right > editorBounds.right - padding) {\n        aiSubmenuRef.current!.setAttribute('data-right-edge', 'true')\n      } else {\n        aiSubmenuRef.current!.removeAttribute('data-right-edge')\n      }\n\n      // 检测下边界 - 基于编辑器边缘\n      if (rect.bottom > editorBounds.bottom - padding) {\n        aiSubmenuRef.current!.setAttribute('data-bottom-edge', 'true')\n      } else {\n        aiSubmenuRef.current!.removeAttribute('data-bottom-edge')\n      }\n    }\n\n    const raf = requestAnimationFrame(checkSubmenuBounds)\n    return () => cancelAnimationFrame(raf)\n  }, [showAISubmenu, show])\n\n  // 翻译子菜单边界检测\n  useEffect(() => {\n    if (!showTranslateSubmenu || !translateSubmenuRef.current) return\n\n    const checkTranslateBounds = () => {\n      const rect = translateSubmenuRef.current!.getBoundingClientRect()\n\n      // 直接获取最新编辑器边界\n      const editorElement = document.querySelector('.ProseMirror')\n      if (!editorElement) return\n\n      const editorBounds = editorElement.getBoundingClientRect()\n      const padding = 8\n\n      // 检测右边界 - 基于编辑器边缘\n      if (rect.right > editorBounds.right - padding) {\n        translateSubmenuRef.current!.setAttribute('data-translate-submenu-right', 'true')\n      } else {\n        translateSubmenuRef.current!.removeAttribute('data-translate-submenu-right')\n      }\n    }\n\n    const raf = requestAnimationFrame(checkTranslateBounds)\n    return () => cancelAnimationFrame(raf)\n  }, [showTranslateSubmenu, show])\n\n  useEffect(() => {\n    const updateHandler = () => updatePosition()\n\n    // 初始化时检查是否有有效的选区\n    const { selection } = editor.state\n    const { from, to } = selection\n\n    // 只有在有选中文本时才显示工具栏\n    if (from !== to) {\n      updatePosition()\n    } else {\n      setShow(false)\n    }\n\n    editor.on('selectionUpdate', updateHandler)\n    editor.on('transaction', updatePosition)\n\n    return () => {\n      editor.off('selectionUpdate', updateHandler)\n      editor.off('transaction', updatePosition)\n    }\n  }, [editor, updatePosition])\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n        setShow(false)\n        setShowAISubmenu(false)\n        setShowTranslateSubmenu(false)\n        setIsInteractingWithMenu(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  // Update position on scroll\n  useEffect(() => {\n    const scrollContainer = document.querySelector('.ProseMirror')?.parentElement\n    if (!scrollContainer) return\n\n    const handleScroll = () => {\n      if (show) {\n        updatePosition()\n      }\n    }\n\n    scrollContainer.addEventListener('scroll', handleScroll, { passive: true })\n    return () => scrollContainer.removeEventListener('scroll', handleScroll)\n  }, [show, updatePosition])\n\n  const setLink = useCallback(() => {\n    if (showLinkInput) {\n      if (linkUrl === '') {\n        editor.chain().focus().extendMarkRange('link').unsetLink().run()\n      } else {\n        editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run()\n      }\n      setShowLinkInput(false)\n      setLinkUrl('')\n    } else {\n      const previousUrl = editor.getAttributes('link').href\n      setLinkUrl(previousUrl || '')\n      setShowLinkInput(true)\n    }\n  }, [editor, linkUrl, showLinkInput])\n\n  const toggleBold = () => editor.chain().focus().toggleBold().run()\n  const toggleItalic = () => editor.chain().focus().toggleItalic().run()\n  const toggleStrike = () => editor.chain().focus().toggleStrike().run()\n  const toggleUnderline = () => editor.chain().focus().toggleUnderline().run()\n  const toggleCode = () => editor.chain().focus().toggleCode().run()\n  const toggleHighlight = () => editor.chain().focus().toggleHighlight().run()\n  const toggleBlockquote = () => editor.chain().focus().toggleBlockquote().run()\n  const toggleBulletList = () => editor.chain().focus().toggleBulletList().run()\n  const toggleOrderedList = () => editor.chain().focus().toggleOrderedList().run()\n  const toggleTaskList = () => editor.chain().focus().toggleTaskList().run()\n  const toggleCodeBlock = () => editor.chain().focus().toggleCodeBlock().run()\n\n  const handleQuoteToChat = useCallback(() => {\n    onQuoteToChat?.()\n    setShow(false)\n    setShowAISubmenu(false)\n  }, [onQuoteToChat])\n\n  const isActive = (name: string, attrs?: Record<string, unknown>) =>\n    editor.isActive(name, attrs)\n\n  if (!show) return null\n\n  return (\n    <div\n      ref={menuRef}\n      className=\"absolute z-50 transition-[top,left] duration-150 ease-out\"\n      style={{\n        top: position.top,\n        left: position.left\n      }}\n    >\n      {/* 工具栏 */}\n      <div\n        className=\"flex items-center gap-0.5 px-1 py-1 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border border-border rounded-lg shadow-lg\"\n      >\n        {/* AI 操作 */}\n        <div className=\"relative\">\n          <button\n            className={cn('p-1.5 rounded hover:bg-muted transition-colors text-primary', showAISubmenu && 'bg-muted')}\n            onClick={() => setShowAISubmenu(!showAISubmenu)}\n            title={t('bubbleMenu.ai')}\n          >\n            <Sparkles className=\"w-4 h-4\" />\n          </button>\n\n          {showAISubmenu && (\n            <div\n              ref={aiSubmenuRef}\n              className=\"absolute top-full mt-1 py-1 bg-background border border-border rounded-lg shadow-lg min-w-32 z-50 data-right-edge:left-auto data-right-edge:right-0 data-right-edge:translate-x-0 data-bottom-edge:top-full data-bottom-edge:mt-1 data-bottom-edge:translate-y-0\"\n            >\n              <button className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => { setShowAISubmenu(false); onAIPolish?.() }}>\n                <Sparkles className=\"w-3.5 h-3.5\" /><span>{t('bubbleMenu.polish')}</span>\n              </button>\n              <button className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => { setShowAISubmenu(false); onAIConcise?.() }}>\n                <Minimize2 className=\"w-3.5 h-3.5\" /><span>{t('bubbleMenu.concise')}</span>\n              </button>\n              <button className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => { setShowAISubmenu(false); onAIExpand?.() }}>\n                <Maximize2 className=\"w-3.5 h-3.5\" /><span>{t('bubbleMenu.expand')}</span>\n              </button>\n\n              <div className=\"border-t border-border my-1\" />\n\n              <button className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => setShowTranslateSubmenu(!showTranslateSubmenu)}>\n                <Languages className=\"w-3.5 h-3.5\" /><span>{t('bubbleMenu.translate')}</span><ChevronRight className={cn('w-3.5 h-3.5 ml-auto transition-transform', showTranslateSubmenu && 'rotate-90')} />\n              </button>\n\n              {showTranslateSubmenu && (\n                <div\n                  ref={translateSubmenuRef}\n                  className=\"absolute top-0 left-full ml-1 py-1 bg-background border border-border rounded-lg shadow-lg min-w-40 z-50 max-h-60 overflow-y-auto data-translate-submenu-right:left-auto data-translate-submenu-right:right-full data-translate-submenu-right:ml-0 data-translate-submenu-right:mr-1\"\n                  data-submenu=\"translate\"\n                >\n                  {POPULAR_LANGUAGES.map((lang) => (\n                    <button key={lang.code} className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => { setShowAISubmenu(false); setShowTranslateSubmenu(false); handleTranslate(lang.code) }}>\n                      <span>{t(`bubbleMenu.${lang.i18nKey}`)}</span>\n                    </button>\n                  ))}\n                  <div className=\"border-t border-border my-1\" />\n                  <div className=\"px-3 py-1 flex items-center gap-1\">\n                    <input type=\"text\" placeholder={t('bubbleMenu.customLanguagePlaceholder')} value={customTranslateLang} onChange={(e) => setCustomTranslateLang(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleCustomTranslate() } else if (e.key === 'Escape') { setShowTranslateSubmenu(false); setCustomTranslateLang('') } }} className=\"w-full px-2 py-1 text-sm bg-muted rounded border border-border focus:outline-none focus:ring-1 focus:ring-primary\" autoFocus />\n                  </div>\n                </div>\n              )}\n\n              <button className=\"w-full px-3 py-1.5 text-left text-sm hover:bg-muted flex items-center gap-2\" onClick={() => { setShowAISubmenu(false); handleQuoteToChat() }}>\n                <MessageCircle className=\"w-3.5 h-3.5\" /><span>{t('bubbleMenu.quoteToChat')}</span>\n              </button>\n            </div>\n          )}\n        </div>\n\n        <div className=\"w-px h-5 bg-border mx-1\" />\n\n        {/* 文本格式化 */}\n        <div className=\"flex gap-0.5\">\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('bold') && 'bg-muted text-primary')} onClick={toggleBold} title={t('bubbleMenu.bold')}><Bold className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('italic') && 'bg-muted text-primary')} onClick={toggleItalic} title={t('bubbleMenu.italic')}><Italic className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('strike') && 'bg-muted text-primary')} onClick={toggleStrike} title={t('bubbleMenu.strike')}><Strikethrough className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('underline') && 'bg-muted text-primary')} onClick={toggleUnderline} title={t('bubbleMenu.underline')}><Underline className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('code') && 'bg-muted text-primary')} onClick={toggleCode} title={t('bubbleMenu.inlineCode')}><Code className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('highlight') && 'bg-muted text-primary')} onClick={toggleHighlight} title={t('bubbleMenu.highlight')}><Highlighter className=\"w-4 h-4\" /></button>\n        </div>\n\n        <div className=\"w-px h-5 bg-border mx-1\" />\n\n        {/* 链接 */}\n        <div className=\"relative\">\n          {showLinkInput ? (\n            <div className=\"flex items-center gap-1 px-1\">\n              <input type=\"url\" placeholder={t('bubbleMenu.linkPlaceholder')} value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { setLink() } else if (e.key === 'Escape') { setShowLinkInput(false); setLinkUrl('') } }} className=\"w-32 px-2 py-1 text-sm bg-muted rounded border border-border focus:outline-none focus:ring-1 focus:ring-primary\" autoFocus />\n              <button className=\"p-1 rounded hover:bg-muted text-xs\" onClick={setLink}>{t('bubbleMenu.confirm')}</button>\n              <button className=\"p-1 rounded hover:bg-muted text-xs\" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>{t('bubbleMenu.cancel')}</button>\n            </div>\n          ) : (\n            <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('link') && 'bg-muted text-primary')} onClick={setLink} title={t('bubbleMenu.link')}><Link className=\"w-4 h-4\" /></button>\n          )}\n        </div>\n\n        <div className=\"w-px h-5 bg-border mx-1\" />\n\n        {/* 块级元素 */}\n        <div className=\"flex gap-0.5\">\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('blockquote') && 'bg-muted text-primary')} onClick={toggleBlockquote} title={t('bubbleMenu.blockquote')}><Quote className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('bulletList') && 'bg-muted text-primary')} onClick={toggleBulletList} title={t('bubbleMenu.bulletList')}><List className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('orderedList') && 'bg-muted text-primary')} onClick={toggleOrderedList} title={t('bubbleMenu.orderedList')}><ListOrdered className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('taskList') && 'bg-muted text-primary')} onClick={toggleTaskList} title={t('bubbleMenu.taskList')}><CheckSquare className=\"w-4 h-4\" /></button>\n          <button className={cn('p-1.5 rounded hover:bg-muted transition-colors', isActive('codeBlock') && 'bg-muted text-primary')} onClick={toggleCodeBlock} title={t('bubbleMenu.codeBlock')}><Code className=\"w-4 h-4\" /></button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default BubbleMenu\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/export-menu.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport {\n  FileText,\n  FileCode,\n  FileJson,\n  Download,\n  FileType,\n} from 'lucide-react'\nimport html2canvas from 'html2canvas'\nimport jsPDF from 'jspdf'\nimport { useCallback, useRef, useState, useEffect } from 'react'\nimport { cn } from '@/lib/utils'\nimport useArticleStore from '@/stores/article'\n\ninterface ExportMenuProps {\n  editor: Editor\n}\n\nexport function ExportMenu({ editor }: ExportMenuProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const menuRef = useRef<HTMLDivElement>(null)\n\n  // Get content as different formats\n  const getMarkdown = useCallback(() => {\n    return editor.getMarkdown()\n  }, [editor])\n\n  const getHtml = useCallback(() => {\n    return editor.getHTML()\n  }, [editor])\n\n  const getJson = useCallback(() => {\n    return JSON.stringify(editor.getJSON(), null, 2)\n  }, [editor])\n\n  const getText = useCallback(() => {\n    return editor.getText()\n  }, [editor])\n\n  // Download file helper\n  const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {\n    const blob = new Blob([content], { type: mimeType })\n    const url = URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    link.download = filename\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    URL.revokeObjectURL(url)\n  }, [])\n\n  // Export handlers\n  const exportMarkdown = useCallback(() => {\n    const content = getMarkdown()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.md`, 'text/markdown')\n    setIsOpen(false)\n  }, [getMarkdown, downloadFile])\n\n  const exportHtml = useCallback(() => {\n    const content = getHtml()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    const htmlContent = `<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>${fileName}</title>\n  <style>\n    body {\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n      max-width: 800px;\n      margin: 0 auto;\n      padding: 40px 20px;\n      line-height: 1.6;\n      color: #333;\n    }\n    pre {\n      background: #f6f8fa;\n      padding: 16px;\n      border-radius: 6px;\n      overflow-x: auto;\n    }\n    code {\n      background: #f6f8fa;\n      padding: 0.2em 0.4em;\n      border-radius: 3px;\n    }\n    blockquote {\n      border-left: 4px solid #dfe2e5;\n      margin: 0;\n      padding-left: 16px;\n      color: #6a737d;\n    }\n    table {\n      border-collapse: collapse;\n      width: 100%;\n    }\n    table th, table td {\n      border: 1px solid #dfe2e5;\n      padding: 8px 12px;\n    }\n    table th {\n      background: #f6f8fa;\n    }\n  </style>\n</head>\n<body>\n${content}\n</body>\n</html>`\n    downloadFile(htmlContent, `${fileName}.html`, 'text/html')\n    setIsOpen(false)\n  }, [getHtml, downloadFile])\n\n  const exportJson = useCallback(() => {\n    const content = getJson()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.json`, 'application/json')\n    setIsOpen(false)\n  }, [getJson, downloadFile])\n\n  const exportText = useCallback(() => {\n    const content = getText()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.txt`, 'text/plain')\n    setIsOpen(false)\n  }, [getText, downloadFile])\n\n  const exportPdf = useCallback(async () => {\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n\n    // Get editor content element\n    const editorElement = document.querySelector('.tiptap') || document.querySelector('.ProseMirror')\n    if (!editorElement) {\n      console.error('Editor element not found')\n      setIsOpen(false)\n      return\n    }\n\n    try {\n      // Create a temporary container for PDF rendering\n      const container = document.createElement('div')\n      container.innerHTML = editorElement.innerHTML\n      container.style.width = '595px' // A4 width in points\n      container.style.padding = '40px'\n      container.style.background = 'white'\n      container.style.fontFamily = '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif'\n      container.style.fontSize = '12px'\n      container.style.lineHeight = '1.6'\n      container.style.color = '#333'\n\n      // Add basic styles for PDF\n      const styles = container.querySelectorAll('style, link[rel=\"stylesheet\"]')\n      styles.forEach(s => s.remove())\n\n      document.body.appendChild(container)\n\n      const canvas = await html2canvas(container as HTMLElement, {\n        scale: 2,\n        useCORS: true,\n        logging: false,\n        backgroundColor: '#ffffff',\n      })\n\n      document.body.removeChild(container)\n\n      const imgData = canvas.toDataURL('image/png')\n      const pdf = new jsPDF({\n        orientation: 'portrait',\n        unit: 'pt',\n        format: 'a4',\n      })\n\n      const imgWidth = 595 // A4 width in points\n      const pageHeight = 842 // A4 height in points\n      const imgHeight = (canvas.height * imgWidth) / canvas.width\n      let heightLeft = imgHeight\n      let position = 0\n\n      // Add first page\n      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)\n      heightLeft -= pageHeight\n\n      // Add additional pages if needed\n      while (heightLeft > 0) {\n        position = heightLeft - imgHeight\n        pdf.addPage()\n        pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)\n        heightLeft -= pageHeight\n      }\n\n      pdf.save(`${fileName}.pdf`)\n    } catch (error) {\n      console.error('PDF export failed:', error)\n    }\n\n    setIsOpen(false)\n  }, [downloadFile])\n\n  // Close menu when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (e: MouseEvent) => {\n      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {\n        setIsOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  return (\n    <div ref={menuRef} className=\"export-menu relative\">\n      <button\n        onClick={() => setIsOpen(!isOpen)}\n        className={cn(\n          'flex items-center justify-center w-5 h-4 rounded transition-colors',\n          'hover:bg-[hsl(var(--accent))]',\n          isOpen && 'bg-[hsl(var(--accent))]'\n        )}\n        title=\"导出\"\n      >\n        <Download size={10} />\n      </button>\n\n      {isOpen && (\n        <div className=\"absolute bottom-full left-0 mb-1 min-w-[160px] bg-[hsl(var(--background))] border border-[hsl(var(--border))] rounded-lg shadow-lg overflow-hidden\">\n          <button\n            onClick={exportMarkdown}\n            className=\"w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[hsl(var(--muted))] transition-colors\"\n          >\n            <FileText size={14} />\n            <span>Markdown (.md)</span>\n          </button>\n          <button\n            onClick={exportHtml}\n            className=\"w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[hsl(var(--muted))] transition-colors\"\n          >\n            <FileCode size={14} />\n            <span>HTML (.html)</span>\n          </button>\n          <button\n            onClick={exportJson}\n            className=\"w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[hsl(var(--muted))] transition-colors\"\n          >\n            <FileJson size={14} />\n            <span>JSON (.json)</span>\n          </button>\n          <button\n            onClick={exportText}\n            className=\"w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[hsl(var(--muted))] transition-colors\"\n          >\n            <FileText size={14} />\n            <span>纯文本 (.txt)</span>\n          </button>\n          <button\n            onClick={exportPdf}\n            className=\"w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[hsl(var(--muted))] transition-colors\"\n          >\n            <FileType size={14} />\n            <span>PDF (.pdf)</span>\n          </button>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default ExportMenu"
  },
  {
    "path": "src/app/core/main/editor/markdown/floating-table-menu.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport {\n  TableIcon,\n  Columns,\n  Rows,\n  Trash2,\n  AlignLeft,\n  AlignCenter,\n  AlignRight,\n} from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { cn } from '@/lib/utils'\n\ninterface FloatingTableMenuProps {\n  editor: Editor\n}\n\nexport function FloatingTableMenu({ editor }: FloatingTableMenuProps) {\n  const [show, setShow] = useState(false)\n  const [position, setPosition] = useState({ top: 0, left: 0 })\n  const menuRef = useRef<HTMLDivElement>(null)\n\n  // Calculate menu position based on table selection\n  const updatePosition = useCallback(() => {\n    const { from } = editor.state.selection\n\n    // Check if we're inside a table using TipTap's isActive method\n    const isInsideTable = editor.isActive('table')\n\n    if (!isInsideTable) {\n      setShow(false)\n      return\n    }\n\n    // Get editor bounds and scroll container\n    const editorElement = document.querySelector('.ProseMirror')\n    const scrollContainer = editorElement?.parentElement\n    if (!editorElement || !scrollContainer) return\n\n    const containerBounds = scrollContainer.getBoundingClientRect()\n\n    // Get the coordinates of the selection\n    const coords = editor.view.coordsAtPos(from)\n\n    // 转换为滚动容器内的相对坐标\n    const relativeTop = coords.bottom - containerBounds.top + scrollContainer.scrollTop + 10\n    const relativeLeft = coords.left - containerBounds.left + scrollContainer.scrollLeft\n\n    // 边界检测：left 在 [0, 容器宽度 - 菜单宽度] 范围内\n    const currentMenuWidth = menuRef.current?.offsetWidth || 200\n    const maxLeft = Math.max(0, containerBounds.width - currentMenuWidth)\n    const left = Math.min(relativeLeft, maxLeft)\n\n    setPosition({ top: relativeTop, left })\n    setShow(true)\n  }, [editor])\n\n  // Update position on selection change\n  useEffect(() => {\n    const updateHandler = () => updatePosition()\n\n    editor.on('selectionUpdate', updateHandler)\n    editor.on('transaction', updateHandler)\n\n    return () => {\n      editor.off('selectionUpdate', updateHandler)\n      editor.off('transaction', updateHandler)\n    }\n  }, [editor, updatePosition])\n\n  // Hide menu when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {\n        setShow(false)\n      }\n    }\n\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => document.removeEventListener('mousedown', handleClickOutside)\n  }, [])\n\n  // Update position on scroll\n  useEffect(() => {\n    const scrollContainer = document.querySelector('.ProseMirror')?.parentElement\n    if (!scrollContainer) return\n\n    const handleScroll = () => {\n      if (show) {\n        updatePosition()\n      }\n    }\n\n    scrollContainer.addEventListener('scroll', handleScroll, { passive: true })\n    return () => scrollContainer.removeEventListener('scroll', handleScroll)\n  }, [show, updatePosition])\n\n  const canInsertTable = editor.can().insertTable({ rows: 3, cols: 3, withHeaderRow: true })\n\n  const insertTable = useCallback(() => {\n    editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()\n    setShow(false)\n  }, [editor])\n\n  const addColumnBefore = useCallback(() => {\n    editor.chain().focus().addColumnBefore().run()\n  }, [editor])\n\n  const addColumnAfter = useCallback(() => {\n    editor.chain().focus().addColumnAfter().run()\n  }, [editor])\n\n  const addRowBefore = useCallback(() => {\n    editor.chain().focus().addRowBefore().run()\n  }, [editor])\n\n  const addRowAfter = useCallback(() => {\n    editor.chain().focus().addRowAfter().run()\n  }, [editor])\n\n  const deleteColumn = useCallback(() => {\n    editor.chain().focus().deleteColumn().run()\n  }, [editor])\n\n  const deleteRow = useCallback(() => {\n    editor.chain().focus().deleteRow().run()\n  }, [editor])\n\n  const deleteTable = useCallback(() => {\n    editor.chain().focus().deleteTable().run()\n  }, [editor])\n\n  const setColumnAlignmentLeft = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'left').run()\n  }, [editor])\n\n  const setColumnAlignmentCenter = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'center').run()\n  }, [editor])\n\n  const setColumnAlignmentRight = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'right').run()\n  }, [editor])\n\n  const isTableActive = editor.isActive('table')\n\n  if (!show) return null\n\n  return (\n    <div\n      ref={menuRef}\n      className=\"absolute z-50 transition-[top,left]\"\n      style={{\n        top: position.top,\n        left: position.left,\n      }}\n    >\n      {/* Arrow */}\n      <div className=\"absolute -top-0 left-6 -translate-x-1/2 translate-y-[-100%]\">\n        <div className=\"w-0 h-0 border-l-8 border-r-8 border-b-8 border-l-transparent border-r-transparent border-b-border\" />\n      </div>\n\n      {/* Table toolbar */}\n      <div className=\"flex items-center gap-0.5 px-1 py-1 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border border-border rounded-lg shadow-lg\">\n        {/* Insert table button (when no table selected) */}\n        {!isTableActive && (\n          <button\n            onClick={insertTable}\n            disabled={!canInsertTable}\n            className={cn(\n              'p-1.5 rounded hover:bg-muted transition-colors',\n              !canInsertTable && 'opacity-50 cursor-not-allowed'\n            )}\n            title=\"插入表格\"\n          >\n            <TableIcon className=\"w-4 h-4\" />\n          </button>\n        )}\n\n        {/* Table operations (when table is active) */}\n        {isTableActive && (\n          <>\n            {/* Add row/column */}\n            <button\n              onClick={addRowBefore}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"在上方插入行\"\n            >\n              <Rows className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={addRowAfter}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"在下方插入行\"\n            >\n              <Rows className=\"w-4 h-4 rotate-180\" />\n            </button>\n            <button\n              onClick={addColumnBefore}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"在左侧插入列\"\n            >\n              <Columns className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={addColumnAfter}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"在右侧插入列\"\n            >\n              <Columns className=\"w-4 h-4 rotate-180\" />\n            </button>\n\n            <div className=\"w-px h-5 bg-border mx-1\" />\n\n            {/* Alignment */}\n            <button\n              onClick={setColumnAlignmentLeft}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"左对齐\"\n            >\n              <AlignLeft className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={setColumnAlignmentCenter}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"居中对齐\"\n            >\n              <AlignCenter className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={setColumnAlignmentRight}\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              title=\"右对齐\"\n            >\n              <AlignRight className=\"w-4 h-4\" />\n            </button>\n\n            <div className=\"w-px h-5 bg-border mx-1\" />\n\n            {/* Delete */}\n            <button\n              onClick={deleteColumn}\n              className=\"p-1.5 rounded hover:bg-destructive/10 text-destructive transition-colors\"\n              title=\"删除列\"\n            >\n              <Trash2 className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={deleteRow}\n              className=\"p-1.5 rounded hover:bg-destructive/10 text-destructive transition-colors\"\n              title=\"删除行\"\n            >\n              <Rows className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={deleteTable}\n              className=\"p-1.5 rounded hover:bg-destructive/10 text-destructive transition-colors\"\n              title=\"删除表格\"\n            >\n              <Trash2 className=\"w-4 h-4\" />\n            </button>\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default FloatingTableMenu\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/copy-button.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { Copy, FileCode, FileJson, FileText } from 'lucide-react'\nimport { useCallback, useState } from 'react'\nimport { toast } from '@/hooks/use-toast'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\n\ninterface CopyButtonProps {\n  editor: Editor\n}\n\ntype CopyFormat = 'markdown' | 'html' | 'json' | 'text'\n\nexport function CopyButton({ editor }: CopyButtonProps) {\n  const [isOpen, setIsOpen] = useState(false)\n  const [copying, setCopying] = useState<CopyFormat | null>(null)\n\n  const copyToClipboard = useCallback(async (content: string, format: CopyFormat) => {\n    try {\n      setCopying(format)\n      await navigator.clipboard.writeText(content)\n      toast({\n        title: '复制成功',\n        description: `已复制为 ${format.toUpperCase()} 格式`\n      })\n    } catch {\n      toast({\n        title: '复制失败',\n        description: '无法复制到剪贴板',\n        variant: 'destructive'\n      })\n    } finally {\n      setCopying(null)\n      setIsOpen(false)\n    }\n  }, [])\n\n  const handleCopyMarkdown = useCallback(() => {\n    copyToClipboard(editor.getMarkdown(), 'markdown')\n  }, [editor, copyToClipboard])\n\n  const handleCopyHtml = useCallback(() => {\n    copyToClipboard(editor.getHTML(), 'html')\n  }, [editor, copyToClipboard])\n\n  const handleCopyJson = useCallback(() => {\n    copyToClipboard(JSON.stringify(editor.getJSON(), null, 2), 'json')\n  }, [editor, copyToClipboard])\n\n  const handleCopyText = useCallback(() => {\n    copyToClipboard(editor.getText(), 'text')\n  }, [editor, copyToClipboard])\n\n  return (\n    <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>\n      <DropdownMenuTrigger asChild>\n        <button\n          title=\"复制\"\n          className=\"p-1 rounded hover:bg-accent focus-visible:outline-none focus-visible:ring-0\"\n        >\n          <Copy className=\"size-3\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"start\"\n        side=\"top\"\n        sideOffset={4}\n      >\n        <DropdownMenuItem onClick={handleCopyMarkdown} disabled={copying !== null}>\n          <FileText size={12} />\n          <span>Markdown</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleCopyHtml} disabled={copying !== null}>\n          <FileCode size={12} />\n          <span>HTML</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleCopyJson} disabled={copying !== null}>\n          <FileJson size={12} />\n          <span>JSON</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleCopyText} disabled={copying !== null}>\n          <FileText size={12} />\n          <span>纯文本</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\nexport default CopyButton\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/export-button.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { Download, FileCode, FileJson, FileText } from 'lucide-react'\nimport { useCallback, useState } from 'react'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport html2canvas from 'html2canvas'\nimport jsPDF from 'jspdf'\nimport useArticleStore from '@/stores/article'\n\ninterface ExportButtonProps {\n  editor: Editor\n}\n\nexport function ExportButton({ editor }: ExportButtonProps) {\n  const [isOpen, setIsOpen] = useState(false)\n\n  // Download file helper\n  const downloadFile = useCallback((content: string, filename: string, mimeType: string) => {\n    const blob = new Blob([content], { type: mimeType })\n    const url = URL.createObjectURL(blob)\n    const link = document.createElement('a')\n    link.href = url\n    link.download = filename\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n    URL.revokeObjectURL(url)\n  }, [])\n\n  // Export as PDF\n  const exportPdf = useCallback(async () => {\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n\n    const editorElement = document.querySelector('.tiptap') || document.querySelector('.ProseMirror')\n    if (!editorElement) {\n      console.error('Editor element not found')\n      setIsOpen(false)\n      return\n    }\n\n    try {\n      const container = document.createElement('div')\n      container.innerHTML = editorElement.innerHTML\n      container.style.width = '595px'\n      container.style.padding = '40px'\n      container.style.background = 'white'\n      container.style.fontFamily = '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif'\n      container.style.fontSize = '12px'\n      container.style.lineHeight = '1.6'\n      container.style.color = '#333'\n\n      const styles = container.querySelectorAll('style, link[rel=\"stylesheet\"]')\n      styles.forEach(s => s.remove())\n\n      document.body.appendChild(container)\n\n      const canvas = await html2canvas(container as HTMLElement, {\n        scale: 2,\n        useCORS: true,\n        logging: false,\n        backgroundColor: '#ffffff',\n      })\n\n      document.body.removeChild(container)\n\n      const imgData = canvas.toDataURL('image/png')\n      const pdf = new jsPDF({\n        orientation: 'portrait',\n        unit: 'pt',\n        format: 'a4',\n      })\n\n      const imgWidth = 595\n      const pageHeight = 842\n      const imgHeight = (canvas.height * imgWidth) / canvas.width\n      let heightLeft = imgHeight\n      let position = 0\n\n      pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)\n      heightLeft -= pageHeight\n\n      while (heightLeft > 0) {\n        position = heightLeft - imgHeight\n        pdf.addPage()\n        pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)\n        heightLeft -= pageHeight\n      }\n\n      pdf.save(`${fileName}.pdf`)\n    } catch (error) {\n      console.error('PDF export failed:', error)\n    }\n\n    setIsOpen(false)\n  }, [])\n\n  const handleExportMarkdown = useCallback(() => {\n    const content = editor.getMarkdown()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.md`, 'text/markdown')\n    setIsOpen(false)\n  }, [editor, downloadFile])\n\n  const handleExportHtml = useCallback(() => {\n    const content = editor.getHTML()\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.html`, 'text/html')\n    setIsOpen(false)\n  }, [editor, downloadFile])\n\n  const handleExportJson = useCallback(() => {\n    const content = JSON.stringify(editor.getJSON(), null, 2)\n    const activeFilePath = useArticleStore.getState().activeFilePath\n    const fileName = activeFilePath?.replace(/\\.md$/, '') || 'document'\n    downloadFile(content, `${fileName}.json`, 'application/json')\n    setIsOpen(false)\n  }, [editor, downloadFile])\n\n  return (\n    <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>\n      <DropdownMenuTrigger asChild>\n        <button\n          title=\"导出\"\n          className=\"p-1 rounded hover:bg-accent focus-visible:outline-none focus-visible:ring-0\"\n        >\n          <Download className=\"size-3\" />\n        </button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"start\"\n        side=\"top\"\n        sideOffset={4}\n      >\n        <DropdownMenuItem onClick={handleExportMarkdown}>\n          <FileText size={12} />\n          <span>Markdown</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleExportHtml}>\n          <FileCode size={12} />\n          <span>HTML</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleExportJson}>\n          <FileJson size={12} />\n          <span>JSON</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={exportPdf}>\n          <FileText size={12} />\n          <span>PDF</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\nexport default ExportButton\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/index.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { WordCount } from './word-count'\nimport { CopyButton } from './copy-button'\nimport { ExportButton } from './export-button'\nimport { SyncTools } from '../sync/sync-tools'\nimport { OutlineToggle } from './outline-toggle'\n\ninterface FooterBarProps {\n  editor: Editor\n  outlineOpen?: boolean\n  onToggleOutline?: () => void\n}\n\nexport function FooterBar({\n  editor,\n  outlineOpen,\n  onToggleOutline,\n}: FooterBarProps) {\n  return (\n    <div className=\"h-6 flex items-center justify-between px-3 border-t border-border bg-background text-xs text-muted-foreground\">\n      {/* Left side: Word count, Copy, Export, Outline */}\n      <div className=\"flex items-center gap-1\">\n        <WordCount editor={editor} />\n        <CopyButton editor={editor} />\n        <ExportButton editor={editor} />\n        <OutlineToggle\n          editor={editor}\n          outlineOpen={outlineOpen}\n          onToggleOutline={onToggleOutline}\n        />\n      </div>\n\n      {/* Right side: Sync tools */}\n      <SyncTools editor={editor} />\n    </div>\n  )\n}\n\nexport default FooterBar\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/outline-toggle.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { List, ListCollapse } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\n\ninterface OutlineToggleProps {\n  editor: Editor\n  outlineOpen?: boolean\n  onToggleOutline?: () => void\n}\n\nexport function OutlineToggle({\n  editor,\n  outlineOpen,\n  onToggleOutline,\n}: OutlineToggleProps) {\n  const t = useTranslations('editor')\n\n  if (!editor) return null\n\n  return (\n    <button\n      onClick={onToggleOutline}\n      className=\"flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-[hsl(var(--muted))] transition-colors\"\n      title={outlineOpen ? t('outline.close') : t('outline.open')}\n    >\n      {outlineOpen ? (\n        <ListCollapse size={14} />\n      ) : (\n        <List size={14} />\n      )}\n      <span>{t('outline.title')}</span>\n    </button>\n  )\n}\n\nexport default OutlineToggle\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/vector-calc.tsx",
    "content": "'use client'\n\nimport { Database, Sparkles } from 'lucide-react'\nimport { useCallback, useState } from 'react'\nimport { cn } from '@/lib/utils'\nimport useVectorStore from '@/stores/vector'\n\ninterface VectorCalcProps {\n  aiCompletionEnabled: boolean\n  onToggleAICompletion: (enabled: boolean) => void\n}\n\nexport function VectorCalc({\n  aiCompletionEnabled,\n  onToggleAICompletion\n}: VectorCalcProps) {\n  const { isProcessing, lastProcessTime, processAllDocuments } = useVectorStore()\n  const [isHoveringVector, setIsHoveringVector] = useState(false)\n\n  const formatLastProcessTime = (timestamp: number | null) => {\n    if (!timestamp) return '未处理'\n    const date = new Date(timestamp)\n    const now = new Date()\n    const diffMs = now.getTime() - date.getTime()\n    const diffMins = Math.floor(diffMs / 60000)\n    const diffHours = Math.floor(diffMs / 3600000)\n    const diffDays = Math.floor(diffMs / 86400000)\n\n    if (diffMins < 1) return '刚刚'\n    if (diffMins < 60) return `${diffMins} 分钟前`\n    if (diffHours < 24) return `${diffHours} 小时前`\n    return `${diffDays} 天前`\n  }\n\n  const handleVectorProcess = useCallback(async () => {\n    if (!isProcessing) {\n      await processAllDocuments()\n    }\n  }, [isProcessing, processAllDocuments])\n\n  return (\n    <>\n      {/* AI Completion Toggle */}\n      <button\n        onClick={() => onToggleAICompletion(!aiCompletionEnabled)}\n        className={cn(\n          'flex items-center gap-0.5 px-1.5 rounded transition-colors',\n          aiCompletionEnabled\n            ? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'\n            : 'hover:bg-[hsl(var(--muted))]'\n        )}\n        title={aiCompletionEnabled ? 'AI 补全已启用' : 'AI 补全已禁用'}\n      >\n        <Sparkles size={10} />\n        <span>AI</span>\n      </button>\n\n      {/* Vector Database Status */}\n      <div\n        className=\"relative\"\n        onMouseEnter={() => setIsHoveringVector(true)}\n        onMouseLeave={() => setIsHoveringVector(false)}\n      >\n        <button\n          onClick={handleVectorProcess}\n          disabled={isProcessing}\n          className={cn(\n            'flex items-center gap-0.5 px-1.5 rounded transition-colors',\n            isProcessing\n              ? 'opacity-50 cursor-wait'\n              : 'hover:bg-[hsl(var(--muted))]'\n          )}\n          title=\"点击重新计算向量\"\n        >\n          <Database size={10} className={cn(isProcessing && 'animate-spin')} />\n          <span>知识库</span>\n        </button>\n\n        {/* Hover tooltip */}\n        {isHoveringVector && (\n          <div className=\"absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-0.5 bg-[hsl(var(--foreground))] text-[hsl(var(--background))] rounded text-[10px] whitespace-nowrap\">\n            {isProcessing\n              ? '正在计算向量...'\n              : `最后更新: ${formatLastProcessTime(lastProcessTime)}`}\n          </div>\n        )}\n      </div>\n    </>\n  )\n}\n\nexport default VectorCalc\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/footer-bar/word-count.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { useMemo } from 'react'\n\ninterface WordCountProps {\n  editor: Editor\n}\n\nexport function WordCount({ editor }: WordCountProps) {\n  const { characters } = useMemo(() => {\n    if (!editor) return { characters: 0, words: 0 }\n    return {\n      characters: editor.storage.characterCount?.characters?.() ?? 0,\n    }\n  }, [editor])\n\n  return (\n    <span className=\"text-xs\">{characters} 字符</span>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/image-bubble-menu.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { Trash2, Link, Type } from 'lucide-react'\nimport { useState, useCallback, useEffect, useRef } from 'react'\nimport { useTranslations } from 'next-intl'\n\ninterface ImageBubbleMenuProps {\n  editor: Editor\n}\n\ninterface ImageInfo {\n  src: string\n  alt: string\n  pos: number\n  rect: DOMRect\n}\n\ntype EditMode = 'none' | 'alt' | 'src'\n\nexport function ImageBubbleMenu({ editor }: ImageBubbleMenuProps) {\n  const t = useTranslations('editor.image')\n  const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null)\n  const [editMode, setEditMode] = useState<EditMode>('none')\n  const [altText, setAltText] = useState('')\n  const [srcText, setSrcText] = useState('')\n  const menuRef = useRef<HTMLDivElement>(null)\n  const isClickingMenu = useRef(false)\n\n  // 处理图片点击\n  const handleImageClick = useCallback((event: MouseEvent) => {\n    if (isClickingMenu.current) return\n\n    const target = event.target as HTMLElement\n    if (target.tagName !== 'IMG') return\n\n    const dom = event.target as HTMLImageElement\n    const rect = dom.getBoundingClientRect()\n\n    // 遍历文档找到对应的图片节点\n    editor.state.doc.descendants((node, pos) => {\n      if (node.type.name === 'image') {\n        const nodeRelativeSrc = node.attrs.relativeSrc || ''\n        const nodeAssetSrc = node.attrs.src || ''\n        const domSrc = dom.src\n        const domRelativeSrc = dom.getAttribute('data-relative-src') || ''\n\n        const matches =\n          nodeRelativeSrc === domRelativeSrc ||\n          nodeRelativeSrc === domRelativeSrc.replace(/^\\.\\//, '') ||\n          nodeAssetSrc === domSrc ||\n          nodeRelativeSrc && domSrc.includes(nodeRelativeSrc) ||\n          nodeRelativeSrc && domRelativeSrc.includes(nodeRelativeSrc)\n\n        if (matches) {\n          editor.chain().setNodeSelection(pos).run()\n          setImageInfo({\n            src: node.attrs.src,\n            alt: node.attrs.alt || '',\n            pos,\n            rect,\n          })\n          setAltText(node.attrs.alt || '')\n          const displaySrc = node.attrs.relativeSrc || node.attrs.src?.replace(/^(tauri|asset|http):\\/\\/localhost\\//, '') || ''\n          setSrcText(displaySrc)\n          setEditMode('none')\n          return false\n        }\n      }\n    })\n  }, [editor])\n\n  // 保存 alt 文本\n  const saveAltText = useCallback(() => {\n    if (imageInfo) {\n      editor.chain().setNodeSelection(imageInfo.pos).updateAttributes('image', { alt: altText }).run()\n      setImageInfo(prev => prev ? { ...prev, alt: altText } : null)\n    }\n    setEditMode('none')\n  }, [editor, imageInfo, altText])\n\n  // 保存 src 地址\n  const saveSrc = useCallback(() => {\n    if (imageInfo && srcText.trim()) {\n      editor.chain().setNodeSelection(imageInfo.pos).updateAttributes('image', {\n        src: srcText.trim(),\n        relativeSrc: srcText.trim()\n      }).run()\n      setImageInfo(prev => prev ? { ...prev, src: srcText.trim() } : null)\n    }\n    setEditMode('none')\n  }, [editor, imageInfo, srcText])\n\n  // 删除图片\n  const deleteImage = useCallback(() => {\n    if (imageInfo) {\n      editor.chain().focus().deleteRange({ from: imageInfo.pos, to: imageInfo.pos + 1 }).run()\n    }\n    setImageInfo(null)\n    setEditMode('none')\n  }, [editor, imageInfo])\n\n  // 点击菜单按钮\n  const handleMenuClick = useCallback((event: React.MouseEvent) => {\n    event.preventDefault()\n    event.stopPropagation()\n    isClickingMenu.current = true\n    setTimeout(() => {\n      isClickingMenu.current = false\n    }, 100)\n  }, [])\n\n  // 点击菜单外部关闭\n  const handleClickOutside = useCallback((event: MouseEvent) => {\n    const target = event.target as HTMLElement\n\n    if (menuRef.current?.contains(target)) return\n    if (target.tagName === 'IMG') return\n\n    setImageInfo(null)\n    setEditMode('none')\n  }, [])\n\n  // 注册事件监听\n  useEffect(() => {\n    const editorElement = document.querySelector('.ProseMirror')\n    if (editorElement) {\n      editorElement.addEventListener('click', handleImageClick as EventListener)\n    }\n\n    document.addEventListener('mousedown', handleClickOutside)\n\n    return () => {\n      if (editorElement) {\n        editorElement.removeEventListener('click', handleImageClick as EventListener)\n      }\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [handleImageClick, handleClickOutside])\n\n  if (!imageInfo) return null\n\n  // 获取滚动容器\n  const scrollContainer = document.querySelector('.ProseMirror')?.parentElement\n  const containerBounds = scrollContainer?.getBoundingClientRect()\n\n  // 始终保持在编辑器横向居中\n  const containerWidth = containerBounds?.width || 800\n  const centerLeft = containerWidth / 2\n\n  // 垂直位置根据图片调整\n  const relativeTop = containerBounds\n    ? imageInfo.rect.top - containerBounds.top + (scrollContainer?.scrollTop || 0) - 8\n    : imageInfo.rect.top - 8\n\n  return (\n    <div\n      ref={menuRef}\n      className=\"absolute z-50\"\n      style={{\n        top: relativeTop,\n        left: centerLeft,\n        transform: 'translateX(-50%)',\n      }}\n    >\n      <div\n        className=\"flex items-center gap-0.5 px-1 py-1 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border border-border rounded-lg shadow-lg\"\n        onClick={handleMenuClick}\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {editMode === 'none' && (\n          <>\n            {/* 修改地址 */}\n            <button\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              onClick={() => {\n                const node = editor.state.doc.nodeAt(imageInfo.pos)\n                setSrcText(node?.attrs.src || imageInfo.src)\n                setEditMode('src')\n              }}\n              title={t('editSrc')}\n            >\n              <Link className=\"w-4 h-4\" />\n            </button>\n\n            {/* 修改 alt */}\n            <button\n              className=\"p-1.5 rounded hover:bg-muted transition-colors\"\n              onClick={() => {\n                const node = editor.state.doc.nodeAt(imageInfo.pos)\n                setAltText(node?.attrs.alt || imageInfo.alt)\n                setEditMode('alt')\n              }}\n              title={t('editAlt')}\n            >\n              <Type className=\"w-4 h-4\" />\n            </button>\n\n            <div className=\"w-px h-5 bg-border mx-1\" />\n\n            {/* 删除 */}\n            <button\n              className=\"p-1.5 rounded hover:bg-muted transition-colors text-destructive\"\n              onClick={deleteImage}\n              title={t('delete')}\n            >\n              <Trash2 className=\"w-4 h-4\" />\n            </button>\n          </>\n        )}\n\n        {editMode === 'alt' && (\n          <div className=\"flex items-center gap-1 px-1\">\n            <Type className=\"w-4 h-4 text-muted-foreground\" />\n            <input\n              type=\"text\"\n              placeholder={t('altPlaceholder')}\n              value={altText}\n              onChange={(e) => setAltText(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  saveAltText()\n                } else if (e.key === 'Escape') {\n                  setEditMode('none')\n                }\n              }}\n              onFocus={(e) => e.target.select()}\n              className=\"w-40 px-2 py-1 text-sm bg-muted rounded border border-border focus:outline-none focus:ring-1 focus:ring-primary\"\n              autoFocus\n            />\n            <button\n              className=\"p-1 rounded hover:bg-muted text-xs\"\n              onClick={saveAltText}\n            >\n              {t('confirm')}\n            </button>\n            <button\n              className=\"p-1 rounded hover:bg-muted text-xs\"\n              onClick={() => setEditMode('none')}\n            >\n              {t('cancel')}\n            </button>\n          </div>\n        )}\n\n        {editMode === 'src' && (\n          <div className=\"flex items-center gap-1 px-1\">\n            <Link className=\"w-4 h-4 text-muted-foreground\" />\n            <input\n              type=\"text\"\n              placeholder={t('srcPlaceholder')}\n              value={srcText}\n              onChange={(e) => setSrcText(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  saveSrc()\n                } else if (e.key === 'Escape') {\n                  setEditMode('none')\n                }\n              }}\n              onFocus={(e) => e.target.select()}\n              className=\"w-60 px-2 py-1 text-sm bg-muted rounded border border-border focus:outline-none focus:ring-1 focus:ring-primary\"\n              autoFocus\n            />\n            <button\n              className=\"p-1 rounded hover:bg-muted text-xs\"\n              onClick={saveSrc}\n            >\n              {t('confirm')}\n            </button>\n            <button\n              className=\"p-1 rounded hover:bg-muted text-xs\"\n              onClick={() => setEditMode('none')}\n            >\n              {t('cancel')}\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default ImageBubbleMenu\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/markdown-input-rules.ts",
    "content": "'use client'\n\nimport { Extension } from '@tiptap/core'\n\n/**\n * Markdown input rules for Tiptap\n * Supports: headings, blockquotes, lists, code blocks, horizontal rules, formatting\n */\nexport const MarkdownInputRules = Extension.create({\n  name: 'markdownInputRules',\n\n  addInputRules() {\n    return [\n      {\n        // Heading 1: # → H1\n        find: /^#\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          tr.replaceWith(range.from, range.to, (state.schema.nodes.heading as any).create({ level: 1 }, state.schema.text('')))\n        },\n      },\n      {\n        // Heading 2: ## → H2\n        find: /^##\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          tr.replaceWith(range.from, range.to, (state.schema.nodes.heading as any).create({ level: 2 }, state.schema.text('')))\n        },\n      },\n      {\n        // Heading 3: ### → H3\n        find: /^###\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          tr.replaceWith(range.from, range.to, (state.schema.nodes.heading as any).create({ level: 3 }, state.schema.text('')))\n        },\n      },\n      {\n        // Blockquote: > → Blockquote\n        find: /^>\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          tr.replaceWith(range.from, range.to, state.schema.nodes.blockquote.create({}, state.schema.text('')))\n        },\n      },\n      {\n        // Bullet list: - or * → Bullet list\n        find: /^[-*]\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          const listItem = state.schema.nodes.list_item.create({}, state.schema.text(''))\n          tr.replaceWith(range.from, range.to, state.schema.nodes.bullet_list.create({}, listItem))\n        },\n      },\n      {\n        // Ordered list: 1. → Ordered list\n        find: /^1\\.\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          const listItem = state.schema.nodes.list_item.create({}, state.schema.text(''))\n          tr.replaceWith(range.from, range.to, state.schema.nodes.ordered_list.create({}, listItem))\n        },\n      },\n      {\n        // Task list unchecked: - [ ] → Task list\n        find: /^- \\[\\]\\s$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          const taskItem = state.schema.nodes.taskItem.create({ checked: false })\n          tr.replaceWith(range.from, range.to, state.schema.nodes.taskList.create({ content: [taskItem] }))\n        },\n      },\n      {\n        // Task list checked: - [x] → Task list\n        find: /^- \\[x\\]\\s$/i,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          const taskItem = state.schema.nodes.taskItem.create({ checked: true })\n          tr.replaceWith(range.from, range.to, state.schema.nodes.taskList.create({ content: [taskItem] }))\n        },\n      },\n      {\n        // Code block: ```\n        find: /^```$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          tr.replaceWith(range.from, range.to, state.schema.nodes.codeBlock.create())\n        },\n      },\n      {\n        // Horizontal rule: --- or ***\n        find: /^(?:---|\\*\\*\\*)$/,\n        undoable: true,\n        handler: ({ state, range }) => {\n          const { tr } = state\n          tr.replaceWith(range.from, range.to, state.schema.nodes.horizontalRule.create())\n        },\n      },\n      {\n        // Bold: **text** or __text__\n        find: /(\\*\\*|__)([^*]+)\\1$/,\n        undoable: true,\n        handler: ({ state, range, match }) => {\n          const { tr } = state\n          const start = range.from\n          const end = range.to\n          const text = match[2]\n          tr.replaceWith(start, end, state.schema.text(text, [state.schema.marks.strong.create()]))\n        },\n      },\n      {\n        // Strike: ~~text~~\n        find: /~~([^~]+)~~$/,\n        undoable: true,\n        handler: ({ state, range, match }) => {\n          const { tr } = state\n          const text = match[1]\n          tr.replaceWith(range.from, range.to, state.schema.text(text, [state.schema.marks.strike.create()]))\n        },\n      },\n      {\n        // Inline code: `text`\n        find: /`([^`]+)`$/,\n        undoable: true,\n        handler: ({ state, range, match }) => {\n          const { tr } = state\n          const text = match[1]\n          tr.replaceWith(range.from, range.to, state.schema.text(text, [state.schema.marks.code.create()]))\n        },\n      },\n    ]\n  },\n})\n\nexport default MarkdownInputRules\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/markdown-paragraph.ts",
    "content": "import { mergeAttributes, Node } from '@tiptap/core'\n\nexport interface MarkdownParagraphOptions {\n  HTMLAttributes: Record<string, any>\n}\n\ndeclare module '@tiptap/core' {\n  interface Commands<ReturnType> {\n    markdownParagraph: {\n      setParagraph: () => ReturnType\n    }\n  }\n}\n\nconst EMPTY_PARAGRAPH_MARKDOWN = '&nbsp;'\nconst NBSP_CHAR = '\\u00A0'\n\nexport const MarkdownParagraph = Node.create<MarkdownParagraphOptions>({\n  name: 'paragraph',\n\n  priority: 1000,\n\n  addOptions() {\n    return {\n      HTMLAttributes: {},\n    }\n  },\n\n  group: 'block',\n\n  content: 'inline*',\n\n  parseHTML() {\n    return [{ tag: 'p' }]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]\n  },\n\n  parseMarkdown: (token, helpers) => {\n    const tokens = token.tokens || []\n    const content = helpers.parseInline(tokens)\n\n    if (\n      content.length === 1 &&\n      content[0].type === 'text' &&\n      (content[0].text === EMPTY_PARAGRAPH_MARKDOWN || content[0].text === NBSP_CHAR)\n    ) {\n      return helpers.createNode('paragraph', undefined, [])\n    }\n\n    return helpers.createNode('paragraph', undefined, content)\n  },\n\n  renderMarkdown: (node, h) => {\n    if (!node) {\n      return ''\n    }\n\n    const content = Array.isArray(node.content) ? node.content : []\n\n    if (content.length === 0) {\n      return EMPTY_PARAGRAPH_MARKDOWN\n    }\n\n    return h.renderChildren(content)\n  },\n\n  addCommands() {\n    return {\n      setParagraph:\n        () =>\n        ({ commands }) => {\n          return commands.setNode(this.name)\n        },\n    }\n  },\n\n  addKeyboardShortcuts() {\n    return {\n      'Mod-Alt-0': () => this.editor.commands.setParagraph(),\n    }\n  },\n})\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/math-editor-dialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useMemo } from 'react'\nimport katex from 'katex'\nimport { Sigma } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'\n\ninterface MathEditorDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  onInsert: (latex: string, type: 'inline' | 'block') => void\n  initialLatex?: string\n  type: 'inline' | 'block'\n  title?: string\n}\n\nexport function MathEditorDialog({\n  open,\n  onOpenChange,\n  onInsert,\n  initialLatex = '',\n  type = 'inline',\n  title = '插入公式',\n}: MathEditorDialogProps) {\n  const [latex, setLatex] = useState(initialLatex)\n  const [error, setError] = useState<string | null>(null)\n\n  useEffect(() => {\n    if (open) {\n      setLatex(initialLatex)\n    }\n  }, [open, initialLatex])\n\n  const renderedHtml = useMemo(() => {\n    try {\n      setError(null)\n      return katex.renderToString(latex, {\n        throwOnError: false,\n        displayMode: type === 'block',\n      })\n    } catch (e) {\n      setError((e as Error).message)\n      return `<span class=\"text-red-500\">Invalid LaTeX</span>`\n    }\n  }, [latex, type])\n\n  const handleInsert = () => {\n    if (!latex.trim()) return\n    onInsert(latex, type)\n    onOpenChange(false)\n    setLatex('')\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n      handleInsert()\n    }\n    if (e.key === 'Escape') {\n      onOpenChange(false)\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[600px]\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <Sigma className=\"w-5 h-5\" />\n            {title}\n          </DialogTitle>\n        </DialogHeader>\n\n        <div className=\"grid gap-4 py-4\">\n          <div>\n            <label className=\"text-sm font-medium mb-2 block\">LaTeX 公式</label>\n            <Input\n              value={latex}\n              onChange={(e) => setLatex(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder=\"输入 LaTeX 公式，例如: \\frac{a}{b}\"\n              className=\"font-mono\"\n            />\n            {error && <p className=\"text-red-500 text-xs mt-1\">{error}</p>}\n          </div>\n\n          <div>\n            <label className=\"text-sm font-medium mb-2 block\">预览</label>\n            <div\n              className={`min-h-[80px] p-4 rounded-lg border bg-muted/30 overflow-x-auto ${\n                type === 'block' ? 'text-center' : ''\n              }`}\n              dangerouslySetInnerHTML={{ __html: renderedHtml }}\n            />\n          </div>\n\n          <div className=\"text-xs text-muted-foreground\">\n            <p>常用公式示例:</p>\n            <ul className=\"list-disc list-inside mt-1 space-y-1\">\n              <li>分数: <code>\\frac&#123;a&#125;&#123;b&#125;</code></li>\n              <li>上标: <code>x^2</code></li>\n              <li>下标: <code>x_n</code></li>\n              <li>平方根: <code>\\sqrt&#123;x&#125;</code></li>\n              <li>求和: <code>\\sum_&#123;i=1&#125;^n</code></li>\n              <li>积分: <code>\\int_a^b f(x) dx</code></li>\n              <li>极限: <code>\\lim_&#123;x \\to \\infty&#125;</code></li>\n              <li>希腊字母: <code>\\alpha, \\beta, \\pi</code></li>\n            </ul>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            取消\n          </Button>\n          <Button onClick={handleInsert} disabled={!latex.trim()}>\n            插入\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/math-extension.tsx",
    "content": "'use client'\n\nimport { Node, mergeAttributes } from '@tiptap/core'\nimport { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'\nimport { useMemo, useState } from 'react'\nimport katex from 'katex'\nimport 'katex/dist/katex.min.css'\n\n// Inline Math Component\nfunction InlineMathView({ node, updateAttributes }: ReactNodeViewProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [latex, setLatex] = useState(node.attrs.latex || '')\n  const [error, setError] = useState<string | null>(null)\n\n  const renderedHtml = useMemo(() => {\n    try {\n      setError(null)\n      return katex.renderToString(node.attrs.latex || '', {\n        throwOnError: false,\n        displayMode: false,\n      })\n    } catch (e) {\n      setError((e as Error).message)\n      return `<span class=\"text-red-500\">Invalid LaTeX</span>`\n    }\n  }, [node.attrs.latex])\n\n  const handleUpdate = () => {\n    updateAttributes({ latex })\n    setIsEditing(false)\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      handleUpdate()\n    }\n    if (e.key === 'Escape') {\n      setLatex(node.attrs.latex || '')\n      setIsEditing(false)\n    }\n  }\n\n  if (isEditing) {\n    return (\n      <NodeViewWrapper className=\"inline-math-wrapper inline\">\n        <input\n          type=\"text\"\n          value={latex}\n          onChange={(e) => setLatex(e.target.value)}\n          onBlur={handleUpdate}\n          onKeyDown={handleKeyDown}\n          className=\"inline-math-input px-2 py-1 border rounded bg-background text-foreground min-w-25 focus:outline-none focus:ring-2 focus:ring-primary\"\n          autoFocus\n        />\n        {error && <span className=\"text-red-500 text-xs ml-2\">{error}</span>}\n      </NodeViewWrapper>\n    )\n  }\n\n  return (\n    <NodeViewWrapper\n      className=\"inline-math-wrapper inline mx-1 px-1 py-0.5 rounded bg-muted/50 cursor-pointer hover:bg-muted transition-colors\"\n      onClick={() => setIsEditing(true)}\n    >\n      <span\n        className=\"tiptap-mathematics-render tiptap-mathematics-render--editable\"\n        data-type=\"inline-math\"\n        dangerouslySetInnerHTML={{ __html: renderedHtml }}\n      />\n    </NodeViewWrapper>\n  )\n}\n\n// Block Math Component\nfunction BlockMathView({ node, updateAttributes }: ReactNodeViewProps) {\n  const [isEditing, setIsEditing] = useState(false)\n  const [latex, setLatex] = useState(node.attrs.latex || '')\n  const [error, setError] = useState<string | null>(null)\n\n  const renderedHtml = useMemo(() => {\n    try {\n      setError(null)\n      return katex.renderToString(node.attrs.latex || '', {\n        throwOnError: false,\n        displayMode: true,\n      })\n    } catch (e) {\n      setError((e as Error).message)\n      return `<span class=\"text-red-500\">Invalid LaTeX</span>`\n    }\n  }, [node.attrs.latex])\n\n  const handleUpdate = () => {\n    updateAttributes({ latex })\n    setIsEditing(false)\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      handleUpdate()\n    }\n    if (e.key === 'Escape') {\n      setLatex(node.attrs.latex || '')\n      setIsEditing(false)\n    }\n  }\n\n  if (isEditing) {\n    return (\n      <NodeViewWrapper className=\"block-math-wrapper my-4\">\n        <textarea\n          value={latex}\n          onChange={(e) => setLatex(e.target.value)}\n          onBlur={handleUpdate}\n          onKeyDown={handleKeyDown}\n          className=\"block-math-input w-full px-3 py-2 border rounded bg-background text-foreground min-h-15 focus:outline-none focus:ring-2 focus:ring-primary font-mono\"\n          autoFocus\n        />\n        {error && <span className=\"text-red-500 text-xs mt-1\">{error}</span>}\n      </NodeViewWrapper>\n    )\n  }\n\n  return (\n    <NodeViewWrapper\n      className=\"block-math-wrapper my-4 p-4 rounded-lg bg-muted/30 cursor-pointer hover:bg-muted/50 transition-colors\"\n      onClick={() => setIsEditing(true)}\n    >\n      <div\n        className=\"tiptap-mathematics-render tiptap-mathematics-render--editable overflow-x-auto\"\n        data-type=\"block-math\"\n        dangerouslySetInnerHTML={{ __html: renderedHtml }}\n      />\n    </NodeViewWrapper>\n  )\n}\n\n// Inline Math Extension\nexport const InlineMath = Node.create({\n  name: 'inlineMath',\n  group: 'inline',\n  inline: true,\n  atom: true,\n\n  addAttributes() {\n    return {\n      latex: {\n        default: '',\n      },\n    }\n  },\n\n  parseHTML() {\n    return [\n      { tag: 'span[data-type=\"inline-math\"]', getAttrs: (node: HTMLElement | string) => {\n        if (typeof node === 'string') return false\n        return { latex: node.getAttribute('data-latex') || '' }\n      }},\n      { tag: 'span[data-latex]', getAttrs: (node: HTMLElement | string) => {\n        if (typeof node === 'string') return false\n        return { latex: node.getAttribute('data-latex') || '' }\n      }},\n    ]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(HTMLAttributes, { 'data-type': 'inline-math' })]\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(InlineMathView)\n  },\n\n  // InputRules are handled by @tiptap/markdown extension\n  // Removed to avoid transaction conflicts with contentType: 'markdown'\n\n  // Configure Markdown serialization for the Tiptap Markdown extension\n  markdownTokenName: 'inline_math',\n\n  // Custom tokenizer for $...$ syntax\n  markdownTokenizer: {\n    name: 'inline_math',\n    level: 'inline',\n    start: (src) => src.indexOf('$'),\n    tokenize: (src, tokens, lexer) => {\n      // Match $...$ (non-greedy, single line)\n      const match = /^\\$([^\\$\\n]+?)\\$/.exec(src)\n      if (!match) return undefined\n\n      return {\n        type: 'inline_math',\n        raw: match[0],\n        content: match[1],\n        tokens: lexer.inlineTokens(match[1]),\n      }\n    },\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  renderMarkdown(node, _helpers) {\n    return `$${node.attrs?.latex ?? ''}$`\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  parseMarkdown(token, _helpers) {\n    return {\n      type: 'inlineMath',\n      attrs: { latex: token.content ?? (token.raw?.slice(1, -1) ?? '') },\n    }\n  },\n})\n\n// Block Math Extension\nexport const BlockMath = Node.create({\n  name: 'blockMath',\n  group: 'block',\n  atom: true,\n\n  addAttributes() {\n    return {\n      latex: {\n        default: '',\n      },\n    }\n  },\n\n  parseHTML() {\n    return [\n      { tag: 'div[data-type=\"block-math\"]', getAttrs: (node: HTMLElement | string) => {\n        if (typeof node === 'string') return false\n        return { latex: node.getAttribute('data-latex') || '' }\n      }},\n      { tag: 'div[data-latex]', getAttrs: (node: HTMLElement | string) => {\n        if (typeof node === 'string') return false\n        return { latex: node.getAttribute('data-latex') || '' }\n      }},\n    ]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'block-math' })]\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(BlockMathView)\n  },\n\n  // InputRules are handled by @tiptap/markdown extension\n  // Removed to avoid transaction conflicts with contentType: 'markdown'\n\n  // Configure Markdown serialization for the Tiptap Markdown extension\n  markdownTokenName: 'block_math',\n\n  // Custom tokenizer for $$...$$ syntax\n  markdownTokenizer: {\n    name: 'block_math',\n    level: 'block',\n    start: (src) => src.indexOf('$$'),\n    tokenize: (src, tokens, lexer) => {\n      // Match $$...$$ (can span multiple lines)\n      const match = /^\\$\\$([\\s\\S]*?)\\$\\$/.exec(src)\n      if (!match) return undefined\n\n      return {\n        type: 'block_math',\n        raw: match[0],\n        content: match[1].trim(),\n        tokens: lexer.blockTokens(match[1].trim()),\n      }\n    },\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  renderMarkdown(node, _helpers) {\n    return `\\n$$${node.attrs?.latex ?? ''}$$\\n`\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  parseMarkdown(token, _helpers) {\n    return {\n      type: 'blockMath',\n      attrs: { latex: token.content ?? (token.raw?.slice(2, -2) ?? '') },\n    }\n  },\n})\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/md-editor-wrapper.tsx",
    "content": "'use client'\n\nimport useArticleStore from '@/stores/article'\nimport { useEffect, useState, useCallback, useRef, RefObject } from 'react'\nimport { TipTapEditor } from './tiptap-editor'\nimport { Outline } from './outline'\nimport { Loader2, Download } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport emitter from '@/lib/emitter'\nimport { DEFAULT_OUTLINE_POSITION, isOutlineOnLeft, normalizeOutlinePosition, type OutlinePosition } from '@/lib/outline-preferences'\nimport { Store } from '@tauri-apps/plugin-store'\n\ninterface MdEditorProps {\n  tabContentsRef: RefObject<Record<string, string>>\n  filePath: string\n}\n\nexport function MdEditor({ tabContentsRef, filePath }: MdEditorProps) {\n  const {\n    saveCurrentArticle,\n    isPulling,\n    setCurrentArticle,\n    activeFilePath,\n    currentArticle,\n    justPulledFile\n  } = useArticleStore()\n\n  const t = useTranslations('article.file.sync')\n  const tEditor = useTranslations('editor')\n  const [initialContent, setInitialContent] = useState<string | null>(null)\n  const [isLoading, setIsLoading] = useState(true)\n  const isCreatingFileRef = useRef(false)\n  // Track loaded state per file path - Bug fix: make this cleanup possible\n  const loadedPathsRef = useRef<Set<string>>(new Set())\n  // Bug fix: Track which file's content is currently in currentArticle\n  const currentArticlePathRef = useRef<string | null>(null)\n  // Bug fix: Track if editor content has been initialized to prevent saving empty content\n  const contentInitializedRef = useRef(false)\n  // Bug fix: Use ref to track loading state since state might be stale in callbacks\n  const isLoadingRef = useRef(true)\n  // Bug fix: Track expected content to detect if editor is behind\n  const expectedContentRef = useRef<string | null>(null)\n  // Outline panel state\n  const [outlineOpen, setOutlineOpen] = useState(false)\n  const [outlinePosition, setOutlinePosition] = useState<OutlinePosition>(DEFAULT_OUTLINE_POSITION)\n  // State for editor instance (to trigger re-render when ready)\n  const [editorInstance, setEditorInstance] = useState<any>(null)\n  // Track if editor has called onEditorReady (meaning it's fully initialized)\n  const [editorReady, setEditorReady] = useState(false)\n  // AI streaming state\n  const [aiStreaming, setAiStreaming] = useState(false)\n  const terminateRef = useRef<(() => void) | undefined>()\n\n  // Bug fix: Listen for file close events to clean up loaded state\n  useEffect(() => {\n    const handleFileClose = (event: { path: string }) => {\n      if (event.path === filePath) {\n        loadedPathsRef.current.delete(filePath)\n      }\n    }\n    emitter.on('editor-file-close', handleFileClose as any)\n    return () => {\n      emitter.off('editor-file-close', handleFileClose as any)\n      // Also clean up on component unmount\n      loadedPathsRef.current.delete(filePath)\n    }\n  }, [filePath])\n\n  // Bug fix: Listen for article opened events to track which file currentArticle belongs to\n  useEffect(() => {\n    const handleArticleOpened = (event: { path: string; content: string }) => {\n      if (event.path === filePath) {\n        currentArticlePathRef.current = filePath\n      } else {\n        // Bug fix: If a different file was opened, clear the reference\n        currentArticlePathRef.current = null\n      }\n    }\n    emitter.on('article-opened', handleArticleOpened as any)\n    return () => {\n      emitter.off('article-opened', handleArticleOpened as any)\n    }\n  }, [filePath])\n\n  // Listen for AI streaming state\n  useEffect(() => {\n    const handleAiStreaming = (event: { isStreaming: boolean; targetFilePath?: string; terminate?: () => void }) => {\n      // Check if this event is for the current file\n      if (event.targetFilePath && event.targetFilePath !== filePath) {\n        // Event is for a different file, ignore\n        return\n      }\n      setAiStreaming(event.isStreaming)\n      if (event.terminate) {\n        terminateRef.current = event.terminate\n      }\n    }\n    emitter.on('editor-ai-streaming', handleAiStreaming as any)\n    return () => {\n      emitter.off('editor-ai-streaming', handleAiStreaming as any)\n    }\n  }, [filePath])\n\n  // Check store for AI generating state on mount and when filePath changes\n  useEffect(() => {\n    // Check if this file is currently being generated by AI\n    const { aiGeneratingFilePath, aiTerminateFn } = useArticleStore.getState()\n    if (aiGeneratingFilePath === filePath) {\n      setAiStreaming(true)\n      if (aiTerminateFn) {\n        terminateRef.current = aiTerminateFn\n      }\n    }\n  }, [filePath])\n\n  useEffect(() => {\n    async function loadOutlinePreferences() {\n      const store = await Store.load('store.json')\n      setOutlineOpen(await store.get<boolean>('enableOutline') || false)\n      setOutlinePosition(normalizeOutlinePosition(await store.get('outlinePosition')))\n    }\n\n    loadOutlinePreferences()\n  }, [])\n\n  // Load content from cache or disk - only on first mount per file\n  useEffect(() => {\n    if (!filePath || loadedPathsRef.current.has(filePath)) return\n\n    // Bug fix: Check cache first\n    if (tabContentsRef.current && tabContentsRef.current[filePath] !== undefined) {\n      setInitialContent(tabContentsRef.current[filePath])\n      loadedPathsRef.current.add(filePath)\n      setIsLoading(false)\n      isLoadingRef.current = false\n      return\n    }\n\n    // Bug fix: Also check if currentArticle belongs to this file (for store initialization)\n    // This handles the case where app restarts and currentArticle is already set\n    if (currentArticle && currentArticle.length > 0) {\n      // Check if the current active file path matches\n      const { activeFilePath: storeActivePath } = useArticleStore.getState()\n      if (storeActivePath === filePath) {\n        setInitialContent(currentArticle)\n        loadedPathsRef.current.add(filePath)\n        setIsLoading(false)\n        isLoadingRef.current = false\n        return\n      }\n    }\n\n    // Load from disk directly (avoid using global currentArticle)\n    const loadContent = async () => {\n      setIsLoading(true)\n      try {\n        const { readTextFile } = await import('@tauri-apps/plugin-fs')\n        const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(filePath)\n\n        let content = ''\n        if (workspace.isCustom) {\n          content = await readTextFile(pathOptions.path)\n        } else {\n          content = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        // Bug fix: Only set isLoading(false) if we have actual content\n        // This prevents flickering when isLoading=false with empty content\n        setInitialContent(content)\n        // Update cache\n        if (tabContentsRef.current) {\n          tabContentsRef.current[filePath] = content\n        }\n        if (content) {\n          setIsLoading(false)\n          isLoadingRef.current = false\n          // Mark content as initialized since we have actual content from disk\n          // This is safe because the content came from disk, not an empty initialization\n          contentInitializedRef.current = true\n        }\n        // If empty, wait for subscription\n      } catch {\n        // File doesn't exist\n        setInitialContent('')\n        // Don't set isLoading(false) here - wait for subscription\n        // This prevents showing empty content briefly before subscription updates\n      }\n    }\n\n    loadContent()\n    loadedPathsRef.current.add(filePath)\n  }, [filePath, tabContentsRef, currentArticle])\n\n  // Subscribe to currentArticle changes (for remote file pull results)\n  // Bug fix: Only update if currentArticle belongs to this file\n  useEffect(() => {\n    // Bug fix: Only process if currentArticle belongs to this file\n    // Also check against store's activeFilePath as fallback\n    const { activeFilePath: storeActivePath } = useArticleStore.getState()\n    const isThisFile = currentArticlePathRef.current === filePath || storeActivePath === filePath\n\n    if (currentArticle && currentArticle.length > 0 && currentArticle !== initialContent && isThisFile) {\n      // Bug fix: Set expected content BEFORE updating initialContent\n      // This ensures handleContentChange knows what to expect\n      expectedContentRef.current = currentArticle\n      setInitialContent(currentArticle)\n      // Update cache\n      if (tabContentsRef.current) {\n        tabContentsRef.current[filePath] = currentArticle\n      }\n      // Bug fix: Don't set isLoadingRef.current = false here!\n      // The editor needs to initialize first, and handleContentChange will\n      // only save if content matches expectedContentRef\n      // We'll set isLoading(false) but isLoadingRef remains true until editor confirms\n      setIsLoading(false)\n      // Mark as initialized so that subsequent saves are allowed\n      contentInitializedRef.current = true\n\n      // Fix cursor jump: Only trigger remote content update if this is a remote pull\n      // This prevents unnecessary setContent during local saves\n      if (justPulledFile) {\n        emitter.emit('editor-content-from-remote', { content: currentArticle })\n      }\n    } else if (currentArticle === '' && isThisFile && initialContent === '') {\n      // Genuinely empty file - hide loading and mark as initialized\n      // Bug fix: Set expected content for empty file\n      expectedContentRef.current = ''\n      setIsLoading(false)\n      isLoadingRef.current = false\n      // Mark as initialized for empty files so user can start typing\n      contentInitializedRef.current = true\n    }\n  }, [currentArticle, filePath, tabContentsRef, initialContent, justPulledFile])\n\n  // Handle content changes - only save if this is the active file\n  const handleContentChange = useCallback((content: string) => {\n    // Bug fix: Don't save if content is empty\n    if (content.length === 0) {\n      return\n    }\n    // Bug fix: If expected content is set and incoming content doesn't match, skip save\n    // This prevents saving stale content during editor initialization race\n    // But clear expectedContentRef so subsequent edits can be saved\n    if (expectedContentRef.current !== null && content !== expectedContentRef.current) {\n      expectedContentRef.current = null\n    }\n    // Bug fix: Skip if content matches what we just loaded (first onUpdate after init)\n    // The editor's onUpdate fires after setContent, so we skip that initial call\n    if (expectedContentRef.current !== null && content === expectedContentRef.current) {\n      // Clear expectedContentRef after first matching update\n      expectedContentRef.current = null\n      return\n    }\n    // Mark as initialized when we receive valid content\n    if (!contentInitializedRef.current) {\n      contentInitializedRef.current = true\n    }\n    // Update cache\n    if (filePath && tabContentsRef.current) {\n      tabContentsRef.current[filePath] = content\n    }\n\n    // Save to disk - only if this is the active file\n    if (filePath && filePath === activeFilePath) {\n      saveCurrentArticle(content)\n    } else if (!filePath && !isCreatingFileRef.current) {\n      // Auto-create untitled file\n      isCreatingFileRef.current = true\n      createUntitledFile(content)\n      isCreatingFileRef.current = false\n    }\n  }, [saveCurrentArticle, filePath, tabContentsRef, activeFilePath])\n\n  // Handle quote to chat - get selected text and emit event\n  const handleQuoteToChat = useCallback(() => {\n    // Get the selected text from the active editor\n    emitter.emit('get-quote-from-editor')\n  }, [])\n\n  // Handle editor ready - store editor instance\n  const handleEditorReady = useCallback((editor: any) => {\n    setEditorInstance(editor)\n    setEditorReady(true)\n  }, [])\n\n  // Reset editor instance and ready state when file changes\n  useEffect(() => {\n    setEditorInstance(null)\n    setEditorReady(false)\n  }, [filePath])\n\n  // Auto-create untitled.md file\n  async function createUntitledFile(content: string) {\n    try {\n      const { exists, writeTextFile } = await import('@tauri-apps/plugin-fs')\n      const workspace = await import('@/lib/workspace').then(m => m.getWorkspacePath())\n      const { getFilePathOptions } = await import('@/lib/workspace')\n\n      let fileName = 'untitled.md'\n      let counter = 1\n      let path = fileName\n\n      while (true) {\n        const pathOptions = await getFilePathOptions(fileName)\n        let fileExists = false\n        if (workspace.isCustom) {\n          fileExists = await exists(pathOptions.path)\n        } else {\n          fileExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n        if (!fileExists) break\n        fileName = `untitled-${counter}.md`\n        path = fileName\n        counter++\n      }\n\n      const pathOptions = await getFilePathOptions(path)\n      if (workspace.isCustom) {\n        await writeTextFile(pathOptions.path, content)\n      } else {\n        await writeTextFile(pathOptions.path, content, { baseDir: pathOptions.baseDir })\n      }\n\n      setCurrentArticle(content)\n      useArticleStore.getState().setActiveFilePath(path)\n      useArticleStore.getState().loadFileTree()\n    } catch {\n    }\n  }\n\n  // Loading state - wait for content to be loaded\n  // 如果正在从远程拉取，优先显示拉取遮罩\n  if (isPulling) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center\">\n        <div className=\"flex flex-col items-center gap-3 text-muted-foreground\">\n          <div className=\"relative\">\n            <Loader2 className=\"size-8 animate-spin\" />\n            <Download className=\"size-4 absolute inset-0 m-auto\" />\n          </div>\n          <div className=\"text-center\">\n            <p className=\"text-sm font-medium\">{t('syncingRemote')}</p>\n            <p className=\"text-xs mt-1\">{t('pullingRemote')}</p>\n          </div>\n        </div>\n      </div>\n    )\n  }\n\n  // 如果 currentArticle 已经有内容，直接显示（拉取完成）\n  const showContent = (currentArticle && currentArticle.length > 0) || initialContent !== null\n  if (isLoading && !showContent) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center\">\n        <Loader2 className=\"size-8 animate-spin text-muted-foreground\" />\n      </div>\n    )\n  }\n\n  return (\n    <div id=\"onboarding-target-editor-content\" className=\"flex-1 relative w-full h-full flex flex-row\">\n      {/* Pull loading overlay */}\n      {isPulling && (\n        <div className=\"absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm\">\n          <div className=\"flex flex-col items-center gap-3 text-muted-foreground\">\n            <div className=\"relative\">\n              <Loader2 className=\"size-8 animate-spin\" />\n              <Download className=\"size-4 absolute inset-0 m-auto\" />\n            </div>\n            <div className=\"text-center\">\n              <p className=\"text-sm font-medium\">{t('syncingRemote')}</p>\n              <p className=\"text-xs mt-1\">{t('pullingRemote')}</p>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {outlineOpen && !isPulling && editorReady && editorInstance && isOutlineOnLeft(outlinePosition) && (\n        <Outline\n          editor={editorInstance}\n          isOpen={outlineOpen}\n          position={outlinePosition}\n        />\n      )}\n\n      {/* Editor - initialContent only set once on mount */}\n      <TipTapEditor\n        initialContent={initialContent || ''}\n        onChange={handleContentChange}\n        placeholder={tEditor('placeholder')}\n        activeFilePath={activeFilePath}\n        onQuoteToChat={handleQuoteToChat}\n        onEditorReady={handleEditorReady}\n        outlineOpen={outlineOpen}\n        onToggleOutline={() => setOutlineOpen(prev => !prev)}\n        editable={!isPulling && !aiStreaming}\n        autoScroll={aiStreaming}\n        showOverlay={aiStreaming}\n        onTerminate={() => {\n          if (terminateRef.current) {\n            terminateRef.current()\n          } else {\n            // If terminateRef is not set, emit abort event\n            emitter.emit('abort-ai-streaming')\n          }}\n        }\n      />\n\n      {outlineOpen && !isPulling && editorReady && editorInstance && !isOutlineOnLeft(outlinePosition) && (\n        <Outline\n          editor={editorInstance}\n          isOpen={outlineOpen}\n          position={outlinePosition}\n        />\n      )}\n    </div>\n  )\n}\n\nexport default MdEditor\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/mermaid-extension.tsx",
    "content": "'use client'\n\nimport { Node, mergeAttributes } from '@tiptap/core'\nimport { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'\nimport { useState, useEffect, useRef, useCallback } from 'react'\nimport { useTranslations } from 'next-intl'\nimport mermaid from 'mermaid'\nimport { Code, Check } from 'lucide-react'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Button } from '@/components/ui/button'\n\n// Initialize mermaid\nmermaid.initialize({\n  startOnLoad: false,\n  theme: 'default',\n  securityLevel: 'loose',\n  fontFamily: 'inherit',\n})\n\n// Diagram type configuration with icons\nconst DIAGRAM_TYPES = [\n  { type: 'flowchart', labelKey: 'flowchart', icon: 'GitBranch', alias: ['flowchart', 'flowchart-v2', 'graph', 'td', 'graph TD', 'graph BT', 'graph LR', 'graph RL'] },\n  { type: 'sequence', labelKey: 'sequence', icon: 'GitCommit', alias: ['sequence', 'sequenceDiagram'] },\n  { type: 'classDiagram', labelKey: 'classDiagram', icon: 'Layers', alias: ['class', 'classDiagram'] },\n  { type: 'stateDiagram', labelKey: 'stateDiagram', icon: 'Activity', alias: ['state', 'stateDiagram', 'stateDiagram-v2'] },\n  { type: 'er', labelKey: 'erDiagram', icon: 'Database', alias: ['er', 'erDiagram'] },\n  { type: 'gantt', labelKey: 'gantt', icon: 'Calendar', alias: ['gantt'] },\n  { type: 'pie', labelKey: 'pie', icon: 'PieChart', alias: ['pie'] },\n  { type: 'journey', labelKey: 'journey', icon: 'Map', alias: ['journey', 'gitGraph'] },\n]\n\n// Detect diagram type from code\nfunction detectDiagramType(code: string): string {\n  const trimmed = code.trim()\n  for (const config of DIAGRAM_TYPES) {\n    // Check first line for type specification\n    const firstLine = trimmed.split('\\n')[0]?.toLowerCase() || ''\n    if (config.alias?.some((alias: string) => firstLine.startsWith(alias) || firstLine === alias)) {\n      return config.type\n    }\n  }\n  return 'flowchart'\n}\n\n// Mermaid Diagram View Component\nfunction MermaidDiagramView({ node, updateAttributes }: ReactNodeViewProps) {\n  const t = useTranslations('editor.mermaid')\n\n  const [isEditing, setIsEditing] = useState(false)\n  const [code, setCode] = useState(node.attrs.code || '')\n  const [diagramType, setDiagramType] = useState(node.attrs.type || 'flowchart')\n  const [svg, setSvg] = useState('')\n  const [error, setError] = useState<string | null>(null)\n  const containerRef = useRef<HTMLDivElement>(null)\n\n  const renderDiagram = useCallback(async () => {\n    if (!code.trim()) {\n      setSvg('')\n      setError(null)\n      return\n    }\n\n    setError(null)\n\n    try {\n      mermaid.parse(code)\n      const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`\n      const { svg: renderedSvg } = await mermaid.render(id, code)\n      setSvg(renderedSvg)\n    } catch (err) {\n      const message = err instanceof Error ? err.message : t('renderError')\n      setError(message)\n      setSvg('')\n    }\n  }, [code, t])\n\n  useEffect(() => {\n    renderDiagram()\n  }, [])\n\n  useEffect(() => {\n    const detected = detectDiagramType(code)\n    if (detected !== diagramType) {\n      setDiagramType(detected)\n    }\n  }, [code, diagramType])\n\n  // 退出编辑模式后刷新预览\n  useEffect(() => {\n    if (!isEditing) {\n      renderDiagram()\n    }\n  }, [isEditing])\n\n  const handleUpdate = () => {\n    updateAttributes({ code, type: diagramType })\n    setIsEditing(false)\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault()\n      handleUpdate()\n    }\n    if (e.key === 'Escape') {\n      setCode(node.attrs.code || '')\n      setIsEditing(false)\n    }\n  }\n\n  const getLabel = (key: string) => {\n    return t(`diagramTypes.${key}`)\n  }\n\n  return (\n    <NodeViewWrapper className=\"mermaid-diagram-wrapper my-4\">\n      {/* Preview Mode */}\n      {!isEditing && (\n        <div\n          className=\"mermaid-preview rounded-lg border border-border bg-card overflow-x-auto cursor-pointer\"\n          onClick={() => setIsEditing(true)}\n        >\n          {error ? (\n            <div className=\"p-4 text-red-500 text-sm\">\n              <p className=\"font-medium\">{t('renderError')}</p>\n              <p className=\"mt-1\">{error}</p>\n              <p className=\"mt-2 text-muted-foreground\">{t('clickToEdit')}</p>\n            </div>\n          ) : svg ? (\n            <div\n              ref={containerRef}\n              className=\"mermaid-svg p-4 flex justify-center\"\n              dangerouslySetInnerHTML={{ __html: svg }}\n            />\n          ) : (\n            <div className=\"p-8 text-center text-muted-foreground\">\n              <span>{t('clickToAdd')}</span>\n            </div>\n          )}\n\n          <div className=\"mermaid-overlay opacity-0 hover:opacity-100 transition-opacity absolute top-2 right-2\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={(e) => {\n                e.stopPropagation()\n                setIsEditing(true)\n              }}\n            >\n              <Code className=\"size-4\" />\n            </Button>\n          </div>\n        </div>\n      )}\n\n      {/* Edit Mode */}\n      {isEditing && (\n        <div className=\"mermaid-editor rounded-lg border border-border bg-card\">\n          <div className=\"flex items-center gap-2 p-2 border-b bg-muted/50\">\n            <Select value={diagramType} onValueChange={setDiagramType}>\n              <SelectTrigger className=\"w-35 h-8 text-xs\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {DIAGRAM_TYPES.map((item) => (\n                  <SelectItem key={item.type} value={item.type}>\n                    {getLabel(item.type)}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n\n            <div className=\"flex-1\" />\n\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={handleUpdate}\n              title={t('done')}\n            >\n              <Check className=\"size-4\" />\n            </Button>\n          </div>\n\n          <textarea\n            value={code}\n            onChange={(e) => setCode(e.target.value)}\n            onKeyDown={handleKeyDown}\n            className=\"w-full h-48 p-3 font-mono text-sm bg-background resize-y focus:outline-none\"\n            placeholder={t('placeholder')}\n            spellCheck={false}\n          />\n\n          {error && (\n            <div className=\"px-3 py-2 text-xs text-red-500 bg-red-50 border-t\">\n              {error}\n            </div>\n          )}\n        </div>\n      )}\n    </NodeViewWrapper>\n  )\n}\n\n// Mermaid Code Block Extension\nexport const MermaidDiagram = Node.create({\n  name: 'mermaidDiagram',\n  group: 'block',\n  atom: true,\n\n  addAttributes() {\n    return {\n      code: {\n        default: '',\n      },\n      type: {\n        default: 'flowchart',\n      },\n    }\n  },\n\n  parseHTML() {\n    return [\n      { tag: 'div[data-type=\"mermaid-diagram\"]' },\n      { tag: 'pre[data-mermaid]' },\n    ]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-diagram' })]\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(MermaidDiagramView)\n  },\n\n  markdownTokenName: 'mermaid',\n\n  markdownTokenizer: {\n    name: 'mermaid',\n    level: 'block',\n    start: (src: string) => {\n      const match = src.match(/^```mermaid\\r?\\n/)\n      return match ? (match.index ?? -1) : -1\n    },\n    tokenize: (src, tokens, lexer) => {\n      const match = /^```mermaid\\r?\\n([\\s\\S]*?)\\r?\\n```/.exec(src)\n      if (!match) return undefined\n\n      const code = match[1]\n      const type = detectDiagramType(code)\n\n      return {\n        type: 'mermaid',\n        raw: match[0],\n        content: code,\n        attrs: { type },\n        tokens: lexer.blockTokens(match[1]),\n      }\n    },\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  renderMarkdown(node, _helpers) {\n    return `\\n\\`\\`\\`mermaid\\n${node.attrs?.code ?? ''}\\n\\`\\`\\`\\n`\n  },\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  parseMarkdown(token, _helpers) {\n    const code = token.content || ''\n    const type = detectDiagramType(code)\n    return {\n      type: 'mermaidDiagram',\n      attrs: { code, type },\n    }\n  },\n})\n\nexport default MermaidDiagram\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/mobile-editor-context-bar-view-model.ts",
    "content": "import {\n  Sparkles,\n  Quote,\n  Bold,\n  Highlighter,\n  MoreHorizontal,\n  Link2,\n  Type,\n  Trash2,\n  Rows3,\n  Columns3,\n  AlignCenter,\n  Italic,\n  Underline,\n  Strikethrough,\n  Code,\n  List,\n  ListOrdered,\n  CheckSquare,\n  SquareCode,\n} from 'lucide-react'\n\nconst ACTION_META = {\n  quote: { label: '引用', icon: Quote },\n  ai: { label: 'AI', icon: Sparkles },\n  bold: { label: '粗体', icon: Bold },\n  highlight: { label: '高亮', icon: Highlighter },\n  more: { label: '更多', icon: MoreHorizontal },\n  italic: { label: '斜体', icon: Italic },\n  underline: { label: '下划线', icon: Underline },\n  strike: { label: '删除线', icon: Strikethrough },\n  code: { label: '行内代码', icon: Code },\n  blockquote: { label: '引用块', icon: Quote },\n  bulletList: { label: '无序列表', icon: List },\n  orderedList: { label: '有序列表', icon: ListOrdered },\n  taskList: { label: '任务列表', icon: CheckSquare },\n  codeBlock: { label: '代码块', icon: SquareCode },\n  'image-src': { label: '地址', icon: Link2 },\n  'image-alt': { label: '说明', icon: Type },\n  'delete-image': { label: '删除', icon: Trash2 },\n  'add-row': { label: '加行', icon: Rows3 },\n  'add-column': { label: '加列', icon: Columns3 },\n  align: { label: '对齐', icon: AlignCenter },\n} as const\n\nexport function buildMobileEditorContextBarViewModel(actions: string[] = []) {\n  return {\n    showSummary: false,\n    showActionText: false,\n    hideScrollbar: true,\n    buttonVariant: 'ghost' as const,\n    buttonSize: 'icon' as const,\n    items: actions\n      .filter((action): action is keyof typeof ACTION_META => action in ACTION_META)\n      .map((action) => ({\n        action,\n        ...ACTION_META[action],\n      })),\n  }\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/mobile-editor-context-bar.tsx",
    "content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\nimport { buildMobileEditorContextBarViewModel } from './mobile-editor-context-bar-view-model'\n\ntype MobileContextMode = 'text' | 'image' | 'table'\n\ntype MobileContextAction =\n  | 'quote'\n  | 'ai'\n  | 'bold'\n  | 'highlight'\n  | 'more'\n  | 'image-src'\n  | 'image-alt'\n  | 'delete-image'\n  | 'add-row'\n  | 'add-column'\n  | 'align'\n\ninterface MobileEditorContextBarProps {\n  mode: MobileContextMode\n  previewText?: string\n  activeActions?: string[]\n  onAction: (action: MobileContextAction) => void\n}\n\nexport function MobileEditorContextBar({\n  mode,\n  previewText,\n  activeActions = [],\n  onAction,\n}: MobileEditorContextBarProps) {\n  void mode\n  void previewText\n  const viewModel = buildMobileEditorContextBarViewModel(activeActions)\n\n  return (\n    <div className=\"mobile-editor-context-bar border-b border-border bg-background/90 px-2 py-1.5 backdrop-blur supports-[backdrop-filter]:bg-background/70\">\n      <div className={cn('flex gap-1 overflow-x-auto', viewModel.hideScrollbar && 'scrollbar-hide')}>\n        {viewModel.items.map((item) => {\n          const typedAction = item.action as MobileContextAction\n          const Icon = item.icon\n\n          return (\n            <Button\n              key={typedAction}\n              type=\"button\"\n              aria-label={item.label}\n              title={item.label}\n              variant={viewModel.buttonVariant}\n              size={viewModel.buttonSize}\n              className={cn(\n                'shrink-0 rounded-2xl text-muted-foreground hover:bg-muted/80 hover:text-foreground',\n                typedAction === 'delete-image' && 'text-destructive hover:bg-destructive/10 hover:text-destructive',\n              )}\n              onClick={() => onAction(typedAction)}\n            >\n              <Icon className=\"size-4\" />\n            </Button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\nexport default MobileEditorContextBar\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/mobile-editor-more-sheet.tsx",
    "content": "'use client'\n\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'\nimport { Input } from '@/components/ui/input'\nimport { Button } from '@/components/ui/button'\n\ntype MobileSheetMode = 'ai' | 'image-src' | 'image-alt' | 'table-align' | 'table-more' | null\n\ninterface MobileEditorMoreSheetProps {\n  open: boolean\n  mode: MobileSheetMode\n  imageSrc: string\n  imageAlt: string\n  onOpenChange: (open: boolean) => void\n  onImageSrcChange: (value: string) => void\n  onImageAltChange: (value: string) => void\n  onSubmitImageSrc: () => void\n  onSubmitImageAlt: () => void\n  onAction: (action: string) => void\n}\n\nfunction ActionButton({ label, onClick, destructive = false }: { label: string; onClick: () => void; destructive?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      className={`w-full rounded-xl border px-3 py-3 text-left text-sm ${destructive ? 'border-destructive/30 text-destructive' : 'border-border text-foreground'}`}\n      onClick={onClick}\n    >\n      {label}\n    </button>\n  )\n}\n\nexport function MobileEditorMoreSheet({\n  open,\n  mode,\n  imageSrc,\n  imageAlt,\n  onOpenChange,\n  onImageSrcChange,\n  onImageAltChange,\n  onSubmitImageSrc,\n  onSubmitImageAlt,\n  onAction,\n}: MobileEditorMoreSheetProps) {\n  const titleMap: Record<Exclude<MobileSheetMode, null>, string> = {\n    ai: 'AI 处理',\n    'image-src': '编辑图片地址',\n    'image-alt': '编辑图片说明',\n    'table-align': '表格对齐',\n    'table-more': '更多表格操作',\n  }\n\n  return (\n    <Drawer open={open} onOpenChange={onOpenChange}>\n      <DrawerContent className=\"max-h-[80vh]\">\n        <DrawerHeader>\n          <DrawerTitle>{mode ? titleMap[mode] : '更多操作'}</DrawerTitle>\n        </DrawerHeader>\n\n        <div className=\"flex flex-col gap-3 overflow-y-auto px-4 pb-6\">\n          {mode === 'ai' && (\n            <>\n              <ActionButton label=\"润色选中文本\" onClick={() => onAction('ai-polish')} />\n              <ActionButton label=\"精简选中文本\" onClick={() => onAction('ai-concise')} />\n              <ActionButton label=\"扩写选中文本\" onClick={() => onAction('ai-expand')} />\n            </>\n          )}\n\n          {mode === 'image-src' && (\n            <>\n              <Input value={imageSrc} onChange={(event) => onImageSrcChange(event.target.value)} placeholder=\"输入图片地址\" />\n              <Button onClick={onSubmitImageSrc}>保存地址</Button>\n            </>\n          )}\n\n          {mode === 'image-alt' && (\n            <>\n              <Input value={imageAlt} onChange={(event) => onImageAltChange(event.target.value)} placeholder=\"输入图片说明\" />\n              <Button onClick={onSubmitImageAlt}>保存说明</Button>\n            </>\n          )}\n\n          {mode === 'table-align' && (\n            <>\n              <ActionButton label=\"左对齐\" onClick={() => onAction('align-left')} />\n              <ActionButton label=\"居中对齐\" onClick={() => onAction('align-center')} />\n              <ActionButton label=\"右对齐\" onClick={() => onAction('align-right')} />\n            </>\n          )}\n\n          {mode === 'table-more' && (\n            <>\n              <ActionButton label=\"在上方插入行\" onClick={() => onAction('add-row-before')} />\n              <ActionButton label=\"在下方插入行\" onClick={() => onAction('add-row-after')} />\n              <ActionButton label=\"在左侧插入列\" onClick={() => onAction('add-column-before')} />\n              <ActionButton label=\"在右侧插入列\" onClick={() => onAction('add-column-after')} />\n              <ActionButton label=\"删除当前行\" onClick={() => onAction('delete-row')} destructive />\n              <ActionButton label=\"删除当前列\" onClick={() => onAction('delete-column')} destructive />\n              <ActionButton label=\"删除整个表格\" onClick={() => onAction('delete-table')} destructive />\n            </>\n          )}\n        </div>\n      </DrawerContent>\n    </Drawer>\n  )\n}\n\nexport default MobileEditorMoreSheet\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/mobile-selection-context.ts",
    "content": "const PRIMARY_ACTIONS = {\n  text: ['quote', 'ai', 'bold', 'highlight', 'italic', 'underline', 'strike', 'code', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock'],\n  image: ['image-src', 'image-alt', 'delete-image', 'more'],\n  table: ['add-row', 'add-column', 'align', 'more'],\n} as const\n\ntype TextSelectionContextInput = {\n  mode: 'text'\n  from: number\n  to: number\n  previewText: string\n}\n\ntype ImageSelectionContextInput = {\n  mode: 'image'\n  pos: number\n  src?: string\n  alt?: string\n}\n\ntype TableSelectionContextInput = {\n  mode: 'table'\n  from?: number\n}\n\ntype MobileSelectionContextInput =\n  | TextSelectionContextInput\n  | ImageSelectionContextInput\n  | TableSelectionContextInput\n  | null\n  | undefined\n\nexport function getMobileContextPrimaryActions(mode: keyof typeof PRIMARY_ACTIONS) {\n  return PRIMARY_ACTIONS[mode] ?? []\n}\n\nexport function buildMobileSelectionContext(input: MobileSelectionContextInput) {\n  if (!input?.mode) {\n    return null\n  }\n\n  if (input.mode === 'text') {\n    const previewText = input.previewText?.trim() ?? ''\n    if (typeof input.from !== 'number' || typeof input.to !== 'number' || input.from >= input.to || !previewText) {\n      return null\n    }\n\n    return {\n      mode: 'text' as const,\n      from: input.from,\n      to: input.to,\n      previewText,\n      actions: getMobileContextPrimaryActions('text'),\n    }\n  }\n\n  if (input.mode === 'image') {\n    if (typeof input.pos !== 'number') {\n      return null\n    }\n\n    return {\n      mode: 'image' as const,\n      pos: input.pos,\n      src: input.src ?? '',\n      alt: input.alt ?? '',\n      actions: getMobileContextPrimaryActions('image'),\n    }\n  }\n\n  if (input.mode === 'table') {\n    return {\n      mode: 'table' as const,\n      from: input.from ?? 0,\n      actions: getMobileContextPrimaryActions('table'),\n    }\n  }\n\n  return null\n}\n\nexport function isMobileSelectionContextStale(\n  context:\n    | { mode: 'text'; from: number; to: number }\n    | { mode: 'image'; pos: number }\n    | { mode: 'table'; from: number }\n    | null,\n  docSize: number,\n) {\n  if (!context) {\n    return true\n  }\n\n  if (context.mode === 'text') {\n    return context.from < 0 || context.to > docSize || context.from >= context.to\n  }\n\n  if (context.mode === 'image') {\n    return context.pos < 0 || context.pos > docSize\n  }\n\n  if (context.mode === 'table') {\n    return typeof context.from === 'number' && (context.from < 0 || context.from > docSize)\n  }\n\n  return false\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/outline.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { Heading1, Heading2, Heading3 } from 'lucide-react'\nimport { useCallback, useEffect, useState, useRef } from 'react'\nimport { cn } from '@/lib/utils'\nimport { getOutlineHeadingTextClass, getOutlinePanelClass } from '@/lib/outline-styles'\n\n\ninterface HeadingItem {\n  level: number\n  text: string\n  id: string\n  pos: number\n  nodeSize: number\n}\n\ninterface OutlineProps {\n  editor: Editor\n  isOpen: boolean\n  position?: 'left' | 'right'\n}\n\nexport function Outline({ editor, isOpen, position = 'right' }: OutlineProps) {\n  const [headings, setHeadings] = useState<HeadingItem[]>([])\n  const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null)\n  // Use ref to always get latest headings in event handlers\n  const headingsRef = useRef<HeadingItem[]>([])\n  // Track if editor is ready - use both ref and state\n  const isEditorReadyRef = useRef(false)\n  const [isReady, setIsReady] = useState(false)\n\n  // Check if editor is ready - wait for view to be available\n  useEffect(() => {\n    if (!editor) {\n      isEditorReadyRef.current = false\n      return\n    }\n\n    // Check periodically if editor view is available\n    const checkEditor = () => {\n      // Check if editor is destroyed\n      if (!editor || (editor as any).isDestroyed) {\n        isEditorReadyRef.current = false\n        return\n      }\n\n      // Check if editor view is ready\n      if (editor.view && editor.view.dom && editor.view.dom.isConnected) {\n        // Additional check: ensure DOM is actually mounted\n        try {\n          // This will throw if not ready\n          editor.view.dom.getBoundingClientRect()\n          isEditorReadyRef.current = true\n          setIsReady(true)\n        } catch {\n          isEditorReadyRef.current = false\n          setIsReady(false)\n          setTimeout(checkEditor, 50)\n          return\n        }\n      } else {\n        isEditorReadyRef.current = false\n        setIsReady(false)\n        setTimeout(checkEditor, 50)\n      }\n    }\n\n    checkEditor()\n  }, [editor])\n\n  // Keep ref in sync with state\n  useEffect(() => {\n    headingsRef.current = headings\n  }, [headings])\n\n  // Extract headings from the editor with position info\n  const extractHeadings = useCallback(() => {\n    if (!editor) return []\n\n    const items: HeadingItem[] = []\n    let index = 0\n\n    editor.state.doc.descendants((node, pos) => {\n      if (node.type.name === 'heading') {\n        const level = node.attrs.level\n        const text = node.textContent.trim() || `Heading ${level}`\n        // Use index to create stable ID that doesn't depend on position\n        const id = `heading-${index}-${level}-${text.slice(0, 20)}`\n        const nodeSize = node.nodeSize\n        items.push({\n          level,\n          text,\n          id,\n          pos,\n          nodeSize,\n        })\n        index++\n      }\n    })\n\n    return items\n  }, [editor])\n\n  // Find the active heading based on cursor position\n  const findActiveHeading = useCallback((cursorPos: number): string | null => {\n    if (headings.length === 0) return null\n\n    // Find the heading that contains the cursor position\n    for (let i = headings.length - 1; i >= 0; i--) {\n      const heading = headings[i]\n      const endPos = heading.pos + heading.nodeSize\n      if (cursorPos >= heading.pos && cursorPos <= endPos) {\n        return heading.id\n      }\n      // Also check if cursor is right after the heading (at the start of next content)\n      if (i === headings.length - 1 && cursorPos <= endPos) {\n        return heading.id\n      }\n    }\n\n    // If cursor is before the first heading, find the first heading that comes after cursor\n    if (cursorPos < headings[0]?.pos) {\n      for (const heading of headings) {\n        if (heading.pos >= cursorPos) {\n          return heading.id\n        }\n      }\n    }\n\n    return headings[0]?.id || null\n  }, [headings])\n\n  // Update headings when editor content changes\n  useEffect(() => {\n    // Check if editor is fully initialized\n    if (!editor || !editor.view || !editor.view.dom) {\n      return\n    }\n\n    // Initial extraction\n    try {\n      setHeadings(extractHeadings())\n    } catch (e) {\n      console.error('[Outline] Error in extractHeadings:', e)\n    }\n\n    // Listen to editor update events to keep headings in sync\n    const handleUpdate = () => {\n      try {\n        setHeadings(extractHeadings())\n      } catch (e) {\n        console.error('[Outline] Error in extractHeadings on update:', e)\n      }\n    }\n\n    editor.on('update', handleUpdate)\n\n    return () => {\n      editor.off('update', handleUpdate)\n    }\n  }, [editor, extractHeadings])\n\n  // Find active heading based on scroll position (viewport)\n  const findActiveHeadingByScroll = useCallback((): string | null => {\n    // Check if editor is fully initialized - use isEditorReadyRef\n    if (!isEditorReadyRef.current || headings.length === 0) return null\n\n    // Get the editor's scrollable element\n    const editorElement = editor.view.dom as HTMLElement\n    const scrollTop = editorElement.scrollTop\n    const viewportTop = scrollTop + 100 // Add some offset for better UX\n\n    // Find the first heading that is above or near the viewport top\n    for (const heading of headings) {\n      const domNode = editor.view.nodeDOM(heading.pos) as HTMLElement | undefined\n      if (domNode) {\n        const rect = domNode.getBoundingClientRect()\n        const editorRect = editorElement.getBoundingClientRect()\n        const relativeTop = rect.top - editorRect.top + scrollTop\n\n        if (relativeTop <= viewportTop) {\n          return heading.id\n        }\n      }\n    }\n\n    return headings[0]?.id || null\n  }, [editor, headings])\n\n  // Update active heading when selection or scroll changes\n  useEffect(() => {\n    // Check if editor is fully initialized\n    if (!editor || !editor.view || !editor.view.dom) return\n\n    const updateActiveHeading = () => {\n      // First try to get heading from cursor position\n      const { from } = editor.state.selection\n      const activeId = findActiveHeading(from)\n      setActiveHeadingId(activeId)\n    }\n\n    // Handle scroll - update based on viewport position\n    const handleScroll = () => {\n      const scrollActiveId = findActiveHeadingByScroll()\n      if (scrollActiveId) {\n        setActiveHeadingId(scrollActiveId)\n      }\n    }\n\n    updateActiveHeading()\n    editor.on('selectionUpdate', updateActiveHeading)\n    editor.on('transaction', updateActiveHeading)\n\n    // Add scroll listener to editor element\n    const editorElement = editor.view.dom as HTMLElement\n    editorElement.addEventListener('scroll', handleScroll)\n\n    return () => {\n      editor.off('selectionUpdate', updateActiveHeading)\n      editor.off('transaction', updateActiveHeading)\n      editorElement.removeEventListener('scroll', handleScroll)\n    }\n  }, [editor, findActiveHeading, findActiveHeadingByScroll, headings])\n\n  // Scroll to heading when clicked\n  const scrollToHeading = useCallback((id: string) => {\n    // Use ref to get latest headings to avoid stale closure\n    const currentHeadings = headingsRef.current\n    const heading = currentHeadings.find(h => h.id === id)\n    if (heading && editor) {\n      // Use stored position directly - it's calculated from current document\n      const targetPos = heading.pos\n\n      // First, focus the editor to ensure it can receive commands\n      editor.commands.focus()\n\n      // Then set the selection to the heading position\n      editor.commands.setTextSelection(targetPos)\n\n      // Then scroll into view\n      // Use setTimeout to ensure the selection is applied first\n      setTimeout(() => {\n        editor.commands.scrollIntoView()\n      }, 0)\n    }\n  }, [editor])\n\n  // Auto-scroll to keep active heading visible\n  useEffect(() => {\n    if (activeHeadingId) {\n      const activeElement = document.getElementById(`outline-${activeHeadingId}`)\n      if (activeElement) {\n        activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })\n      }\n    }\n  }, [activeHeadingId])\n\n  // 如果编辑器还没准备好或没有打开Outline，直接返回 null\n  if (!isOpen || !isReady) return null\n\n  return (\n    <div className={getOutlinePanelClass(position)}>\n      {headings.length === 0 ? (\n        <div className=\"p-4 text-sm text-[hsl(var(--muted-foreground))] text-center\">\n          暂无标题\n        </div>\n      ) : (\n        <ul className=\"p-2 space-y-1\">\n          {headings.map((heading) => (\n            <li key={heading.id}>\n              <button\n                id={`outline-${heading.id}`}\n                onClick={() => scrollToHeading(heading.id)}\n                className={cn(\n                  'w-full min-w-0 text-left px-2 py-1.5 rounded text-sm hover:bg-[hsl(var(--muted))] flex items-start gap-2 transition-colors',\n                  heading.level === 1 ? 'font-semibold' : '',\n                  activeHeadingId === heading.id\n                    ? 'bg-[hsl(var(--accent))] text-[hsl(var(--accent-foreground))]'\n                    : ''\n                )}\n                style={{ paddingLeft: `${(heading.level - 1) * 12 + 8}px` }}\n              >\n                {heading.level === 1 && <Heading1 size={14} className=\"shrink-0 mt-0.5\" />}\n                {heading.level === 2 && <Heading2 size={14} className=\"shrink-0 mt-0.5\" />}\n                {heading.level === 3 && <Heading3 size={14} className=\"shrink-0 mt-0.5\" />}\n                <span className={getOutlineHeadingTextClass()}>{heading.text}</span>\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  )\n}\n\nexport default Outline\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/quote-mark.ts",
    "content": "import { Mark, mergeAttributes } from '@tiptap/core'\n\nexport interface QuoteOptions {\n  HTMLAttributes: Record<string, string>\n}\n\ndeclare module '@tiptap/core' {\n  interface Commands<ReturnType> {\n    quote: {\n      setQuote: () => ReturnType\n      unsetQuote: () => ReturnType\n    }\n  }\n}\n\nexport const QuoteMark = Mark.create<QuoteOptions>({\n  name: 'quote',\n\n  addOptions() {\n    return {\n      HTMLAttributes: {},\n    }\n  },\n\n  addAttributes() {\n    return {\n      'data-quote': {\n        default: 'true',\n      },\n    }\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: 'span[data-quote]',\n      },\n    ]\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {\n      'data-quote': 'true',\n      class: 'tiptap-quote-mark',\n      style: 'border: 2px solid currentColor !important; border-radius: 4px !important; background: hsl(var(--primary) / 0.12);'\n    }), 0]\n  },\n\n  addCommands() {\n    return {\n      setQuote:\n        () =>\n        ({ commands }) => {\n          return commands.setMark(this.name)\n        },\n      unsetQuote:\n        () =>\n        ({ commands }) => {\n          return commands.unsetMark(this.name)\n        },\n    }\n  },\n})\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/quote-session.ts",
    "content": "import type { PendingQuote } from '@/stores/chat'\n\nexport function shouldRestorePendingQuote(\n  pendingQuote: PendingQuote | null,\n  articlePath: string | undefined,\n  docSize: number,\n) {\n  if (!pendingQuote || !articlePath) {\n    return false\n  }\n\n  if (pendingQuote.articlePath !== articlePath) {\n    return false\n  }\n\n  if (typeof pendingQuote.from !== 'number' || typeof pendingQuote.to !== 'number') {\n    return false\n  }\n\n  return pendingQuote.from >= 0 && pendingQuote.to <= docSize && pendingQuote.from < pendingQuote.to\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/search-navigation.ts",
    "content": "export function getResultIndexToFocus(\n  results: Array<{ from: number; to: number }>,\n  requestedIndex = 0\n): number {\n  if (results.length === 0) {\n    return -1\n  }\n\n  if (requestedIndex < 0) {\n    return 0\n  }\n\n  if (requestedIndex >= results.length) {\n    return results.length - 1\n  }\n\n  return requestedIndex\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/search-replace-panel.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { TextSelection } from '@tiptap/pm/state'\nimport { useState, useCallback, useEffect } from 'react'\nimport { Search, X, ChevronDown, ChevronUp, Replace, ReplaceAll } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\n\n// 搜索替换存储类型\ninterface SearchAndReplaceStorage {\n  searchTerm: string\n  replaceTerm: string\n  results: { from: number; to: number }[]\n  resultIndex: number\n  caseSensitive: boolean\n}\n\ninterface SearchReplacePanelProps {\n  editor: Editor\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\n// 获取搜索替换存储的辅助函数\nfunction getSearchAndReplaceStorage(editor: Editor): SearchAndReplaceStorage | undefined {\n  return (editor.storage as any).searchAndReplace\n}\n\n// 辅助函数来运行搜索替换命令\n// 直接触发 transaction 来更新插件状态\nfunction setSearchTerm(editor: Editor, term: string) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage) {\n      storage.searchTerm = term\n      editor.view.dispatch(editor.state.tr)\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction setReplaceTerm(editor: Editor, term: string) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage) {\n      storage.replaceTerm = term\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction setSearchCaseSensitive(editor: Editor, value: boolean) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage) {\n      storage.caseSensitive = value\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction nextResult(editor: Editor) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage && storage.results.length > 0) {\n      const nextIndex = storage.resultIndex + 1\n      storage.resultIndex = nextIndex >= storage.results.length ? 0 : nextIndex\n\n      const result = storage.results[storage.resultIndex]\n      if (result) {\n        const sel = TextSelection.near(editor.state.doc.resolve(result.from))\n        editor.view.dispatch(\n          editor.state.tr.setSelection(sel)\n        )\n        setTimeout(() => {\n          const domPos = editor.view.domAtPos(result.from)\n          if (domPos.node instanceof Element) {\n            domPos.node.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          } else if (domPos.node.parentElement) {\n            domPos.node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          }\n        }, 0)\n      }\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction prevResult(editor: Editor) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage && storage.results.length > 0) {\n      const prevIndex = storage.resultIndex - 1\n      storage.resultIndex = prevIndex < 0 ? storage.results.length - 1 : prevIndex\n\n      const result = storage.results[storage.resultIndex]\n      if (result) {\n        const sel = TextSelection.near(editor.state.doc.resolve(result.from))\n        editor.view.dispatch(\n          editor.state.tr.setSelection(sel)\n        )\n        setTimeout(() => {\n          const domPos = editor.view.domAtPos(result.from)\n          if (domPos.node instanceof Element) {\n            domPos.node.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          } else if (domPos.node.parentElement) {\n            domPos.node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          }\n        }, 0)\n      }\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction replaceCurrent(editor: Editor) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage && storage.results.length > 0 && storage.replaceTerm) {\n      const { from, to } = storage.results[storage.resultIndex]\n      editor.view.dispatch(\n        editor.state.tr.insertText(storage.replaceTerm, from, to)\n      )\n      storage.searchTerm = storage.searchTerm\n      editor.view.dispatch(editor.state.tr)\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nfunction replaceAll(editor: Editor) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage && storage.results.length > 0 && storage.replaceTerm) {\n      for (let i = storage.results.length - 1; i >= 0; i--) {\n        const { from, to } = storage.results[i]\n        editor.view.dispatch(\n          editor.state.tr.insertText(storage.replaceTerm, from, to)\n        )\n      }\n      storage.searchTerm = ''\n      storage.results = []\n      storage.resultIndex = 0\n      editor.view.dispatch(editor.state.tr)\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\n// 直接清除搜索状态\nfunction clearSearch(editor: Editor) {\n  try {\n    const storage = getSearchAndReplaceStorage(editor)\n    if (storage) {\n      storage.searchTerm = ''\n      storage.results = []\n      storage.resultIndex = 0\n      editor.view.dispatch(editor.state.tr)\n    }\n  } catch {\n    // 忽略错误\n  }\n}\n\nexport function SearchReplacePanel({ editor, open, onOpenChange }: SearchReplacePanelProps) {\n  const [searchText, setSearchText] = useState('')\n  const [replaceText, setReplaceText] = useState('')\n  const [caseSensitive, setCaseSensitive] = useState(false)\n  const [resultCount, setResultCount] = useState(0)\n  const [currentIndex, setCurrentIndex] = useState(0)\n\n  // 更新搜索结果计数\n  const updateResults = useCallback(() => {\n    if (!editor || !searchText) {\n      setResultCount(0)\n      setCurrentIndex(0)\n      return\n    }\n\n    const storage = getSearchAndReplaceStorage(editor)\n    const results = storage?.results || []\n    setResultCount(results.length)\n    setCurrentIndex(storage?.resultIndex || 0)\n  }, [editor, searchText])\n\n  // 监听编辑器状态变化\n  useEffect(() => {\n    if (!editor) return\n\n    const handleUpdate = () => {\n      updateResults()\n    }\n\n    editor.on('transaction', handleUpdate)\n    return () => {\n      editor.off('transaction', handleUpdate)\n    }\n  }, [editor, updateResults])\n\n  // 替换当前\n  const handleReplace = useCallback(() => {\n    if (!editor) return\n    replaceCurrent(editor)\n    updateResults()\n  }, [editor, updateResults])\n\n  // 替换全部\n  const handleReplaceAll = useCallback(() => {\n    if (!editor) return\n    replaceAll(editor)\n    updateResults()\n  }, [editor, updateResults])\n\n  // 查找上一个\n  const handlePrev = useCallback(() => {\n    if (!editor) return\n    prevResult(editor)\n    updateResults()\n  }, [editor, updateResults])\n\n  // 查找下一个\n  const handleNext = useCallback(() => {\n    if (!editor) return\n    nextResult(editor)\n    updateResults()\n  }, [editor, updateResults])\n\n  // 关闭面板时清除搜索\n  const handleClose = useCallback(() => {\n    if (editor) {\n      clearSearch(editor)\n    }\n    setSearchText('')\n    setReplaceText('')\n    onOpenChange(false)\n  }, [editor, onOpenChange])\n\n  // 搜索文本变化\n  const handleSearchChange = useCallback((value: string) => {\n    setSearchText(value)\n    if (editor && value) {\n      setSearchTerm(editor, value)\n    } else if (editor) {\n      setSearchTerm(editor, '')\n    }\n    updateResults()\n  }, [editor, updateResults])\n\n  // 替换文本变化\n  const handleReplaceChange = useCallback((value: string) => {\n    setReplaceText(value)\n    if (editor) {\n      setReplaceTerm(editor, value)\n    }\n  }, [editor])\n\n  // 大小写切换\n  const handleCaseSensitiveToggle = useCallback(() => {\n    const newValue = !caseSensitive\n    setCaseSensitive(newValue)\n    if (editor) {\n      setSearchCaseSensitive(editor, newValue)\n      if (searchText) {\n        setSearchTerm(editor, searchText)\n      }\n    }\n    updateResults()\n  }, [editor, caseSensitive, searchText, updateResults])\n\n  // 打开面板时聚焦搜索输入框\n  useEffect(() => {\n    if (open) {\n      setTimeout(() => {\n        const input = document.getElementById('searchAndReplace-replace-input')\n        input?.focus()\n      }, 100)\n    }\n  }, [open])\n\n  if (!open) return null\n\n  return (\n    <div className=\"fixed top-24 left-1/2 -translate-x-1/2 z-50\">\n      <div className=\"bg-background border border-border rounded-lg shadow-lg p-3 min-w-96\">\n        {/* 搜索行 */}\n        <div className=\"flex items-center gap-2\">\n          <div className=\"relative flex-1\">\n            <Search className=\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground\" />\n            <Input\n              id=\"searchAndReplace-replace-input\"\n              placeholder=\"搜索...\"\n              value={searchText}\n              onChange={(e) => handleSearchChange(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  if (e.shiftKey) {\n                    handlePrev()\n                  } else {\n                    handleNext()\n                  }\n                } else if (e.key === 'Escape') {\n                  handleClose()\n                }\n              }}\n              className=\"pl-8 pr-16\"\n            />\n            <div className=\"absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 text-xs text-muted-foreground\">\n              {resultCount > 0 ? (\n                <span>\n                  {currentIndex + 1}/{resultCount}\n                </span>\n              ) : null}\n            </div>\n          </div>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-8\"\n            onClick={handlePrev}\n            disabled={resultCount === 0}\n            title=\"上一个 (Shift+Enter)\"\n          >\n            <ChevronUp className=\"w-4 h-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-8\"\n            onClick={handleNext}\n            disabled={resultCount === 0}\n            title=\"下一个 (Enter)\"\n          >\n            <ChevronDown className=\"w-4 h-4\" />\n          </Button>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-8\"\n            onClick={handleClose}\n            title=\"关闭 (Esc)\"\n          >\n            <X className=\"w-4 h-4\" />\n          </Button>\n        </div>\n\n        {/* 替换行 */}\n        <div className=\"flex items-center gap-2 mt-2\">\n          <div className=\"relative flex-1\">\n            <Replace className=\"absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground\" />\n            <Input\n              placeholder=\"替换为...\"\n              value={replaceText}\n              onChange={(e) => handleReplaceChange(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') {\n                  if (e.shiftKey) {\n                    handleReplaceAll()\n                  } else {\n                    handleReplace()\n                  }\n                }\n              }}\n              className=\"pl-8 pr-8\"\n            />\n          </div>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleReplace}\n            disabled={resultCount === 0}\n            title=\"替换当前 (Enter)\"\n          >\n            <Replace className=\"w-3 h-3 mr-1\" />\n            替换\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleReplaceAll}\n            disabled={resultCount === 0}\n            title=\"替换全部 (Shift+Enter)\"\n          >\n            <ReplaceAll className=\"w-3 h-3 mr-1\" />\n            全部\n          </Button>\n        </div>\n\n        {/* 选项行 */}\n        <div className=\"flex items-center gap-2 mt-2 pt-2 border-t border-border\">\n          <label className=\"flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer\">\n            <input\n              type=\"checkbox\"\n              checked={caseSensitive}\n              onChange={handleCaseSensitiveToggle}\n              className=\"w-3.5 h-3.5\"\n            />\n            区分大小写\n          </label>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/slash-command/index.ts",
    "content": "import { Extension, type Editor, type RawCommands } from '@tiptap/core'\nimport { Suggestion, SuggestionPluginKey } from '@tiptap/suggestion'\nimport { suggestionOptions, findSlashMatch, setMenuKeyDownHandler } from './suggestion'\n\n// Re-export for use in SlashCommandPortal\nexport { setMenuKeyDownHandler }\n\ndeclare module '@tiptap/core' {\n  interface Commands<ReturnType> {\n    slashCommand: {\n      triggerSlashCommand: () => ReturnType\n    }\n  }\n}\n\nexport const SlashCommand = Extension.create({\n  name: 'slashCommand',\n\n  addOptions() {\n    return {\n      suggestion: {\n        char: '/',\n        pluginKey: SuggestionPluginKey,\n        command: ({ editor, range, props }: { editor: Editor; range: any; props: any }) => {\n          props.command({ editor, range })\n        },\n      },\n    }\n  },\n\n  addProseMirrorPlugins() {\n    return [\n      Suggestion({\n        editor: this.editor,\n        char: '/',\n        findSuggestionMatch: findSlashMatch,\n        ...suggestionOptions,\n        pluginKey: SuggestionPluginKey,\n      }),\n    ]\n  },\n\n  addCommands() {\n    return {\n      triggerSlashCommand:\n        () =>\n        ({ editor }) => {\n          const tr = editor.state.tr\n          tr.insertText('/')\n          editor.view.dispatch(tr)\n          editor.view.focus()\n          return true\n        },\n    } as RawCommands\n  },\n})\n\nexport { suggestionOptions }\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/slash-command/slash-command-portal.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useRef } from 'react'\nimport { Editor } from '@tiptap/react'\nimport { SlashMenu, SlashMenuRef } from './slash-menu'\nimport { setMenuKeyDownHandler } from './index'\n\ninterface MenuState {\n  visible: boolean\n  editor: Editor | null\n  clientRect: DOMRect | null\n  query: string\n}\n\n// Menu dimensions\nconst MENU_MAX_HEIGHT = 256 // max-h-64 = 256px\nconst MENU_MIN_WIDTH = 144 // min-w-36 = 144px\nconst MARGIN = 8\n\nfunction calculateMenuPosition(clientRect: DOMRect): { top: number; left: number } {\n  // Default position: below the cursor\n  let top = clientRect.bottom + MARGIN\n  let left = clientRect.left\n\n  // Get viewport dimensions\n  const viewportHeight = window.innerHeight\n  const viewportWidth = window.innerWidth\n\n  // Check if menu would overflow bottom of screen\n  const availableHeightBelow = viewportHeight - clientRect.bottom - MARGIN\n  const availableHeightAbove = clientRect.top - MARGIN\n\n  if (availableHeightBelow < MENU_MAX_HEIGHT && availableHeightAbove > availableHeightBelow) {\n    // Show above the cursor instead\n    top = clientRect.top - MENU_MAX_HEIGHT - MARGIN\n  }\n\n  // Ensure top is not negative\n  top = Math.max(MARGIN, top)\n\n  // Ensure left doesn't overflow right edge\n  if (left + MENU_MIN_WIDTH > viewportWidth - MARGIN) {\n    left = viewportWidth - MENU_MIN_WIDTH - MARGIN\n  }\n\n  // Ensure left is not negative\n  left = Math.max(MARGIN, left)\n\n  return { top, left }\n}\n\nexport const SlashCommandPortal = () => {\n  const [state, setState] = useState<MenuState>({\n    visible: false,\n    editor: null,\n    clientRect: null,\n    query: '',\n  })\n  const menuRef = useRef<SlashMenuRef>(null)\n  const [position, setPosition] = useState<{ top: number; left: number } | null>(null)\n\n  const hideMenu = useCallback(() => {\n    setState((prev) => ({ ...prev, visible: false }))\n    setPosition(null)\n  }, [])\n\n  useEffect(() => {\n    const showHandler = (e: Event) => {\n      const event = e as CustomEvent<{\n        editor: Editor\n        clientRect: DOMRect\n        query: string\n      }>\n      const newPosition = calculateMenuPosition(event.detail.clientRect)\n      setPosition(newPosition)\n      setState({\n        visible: true,\n        editor: event.detail.editor,\n        clientRect: event.detail.clientRect,\n        query: event.detail.query,\n      })\n    }\n\n    const updateHandler = (e: Event) => {\n      const event = e as CustomEvent<{\n        clientRect: DOMRect\n        query: string\n      }>\n      setPosition(calculateMenuPosition(event.detail.clientRect))\n      setState((prev) => ({\n        ...prev,\n        clientRect: event.detail.clientRect,\n        query: event.detail.query,\n      }))\n    }\n\n    const hideHandler = () => {\n      hideMenu()\n    }\n\n    document.addEventListener('slash-command-show', showHandler)\n    document.addEventListener('slash-command-update', updateHandler)\n    document.addEventListener('slash-command-hide', hideHandler)\n\n    return () => {\n      document.removeEventListener('slash-command-show', showHandler)\n      document.removeEventListener('slash-command-update', updateHandler)\n      document.removeEventListener('slash-command-hide', hideHandler)\n    }\n  }, [hideMenu])\n\n  // Register keyDown handler when menu becomes visible\n  useEffect(() => {\n    if (state.visible && menuRef.current) {\n      const handler = (props: { event: KeyboardEvent }) => {\n        return menuRef.current?.onKeyDown?.(props) ?? false\n      }\n      setMenuKeyDownHandler(handler)\n\n      return () => {\n        setMenuKeyDownHandler(null)\n      }\n    }\n  }, [state.visible])\n\n  if (!state.visible || !state.editor || !state.clientRect || !position) return null\n\n  return (\n    <div\n      style={{\n        position: 'fixed',\n        top: position.top,\n        left: position.left,\n        zIndex: 9999,\n      }}\n    >\n      <SlashMenu\n        ref={menuRef}\n        editor={state.editor}\n        clientRect={state.clientRect}\n        query={state.query}\n      />\n    </div>\n  )\n}\n\nSlashCommandPortal.displayName = 'SlashCommandPortal'\n\nexport default SlashCommandPortal\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/slash-command/slash-menu.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle, useRef } from 'react'\nimport { type Editor } from '@tiptap/react'\nimport { useTranslations } from 'next-intl'\nimport { SlashCommandItem, suggestionItems, filterItems } from './suggestion'\nimport { cn } from '@/lib/utils'\n\ninterface SlashMenuProps {\n  editor: Editor\n  clientRect?: DOMRect | null\n  query: string\n}\n\nexport interface SlashMenuRef {\n  onKeyDown: (props: { event: KeyboardEvent }) => boolean\n}\n\nexport const SlashMenu = forwardRef<SlashMenuRef, SlashMenuProps>(({ editor, query }, ref) => {\n  const t = useTranslations('editor.slashCommand')\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  // 构建翻译对象\n  const translations = useMemo(() => ({\n    groups: {\n      ai: t('groups.ai'),\n      heading: t('groups.heading'),\n      list: t('groups.list'),\n      block: t('groups.block'),\n      align: t('groups.align'),\n      embed: t('groups.embed'),\n      math: t('groups.math'),\n      chart: t('groups.chart'),\n    },\n    items: {\n      continue: t('items.continue'),\n      continueDesc: t('items.continueDesc'),\n      heading1: t('items.heading1'),\n      heading1Desc: t('items.heading1Desc'),\n      heading2: t('items.heading2'),\n      heading2Desc: t('items.heading2Desc'),\n      heading3: t('items.heading3'),\n      heading3Desc: t('items.heading3Desc'),\n      bulletList: t('items.bulletList'),\n      bulletListDesc: t('items.bulletListDesc'),\n      orderedList: t('items.orderedList'),\n      orderedListDesc: t('items.orderedListDesc'),\n      taskList: t('items.taskList'),\n      taskListDesc: t('items.taskListDesc'),\n      image: t('items.image'),\n      imageDesc: t('items.imageDesc'),\n      table: t('items.table'),\n      tableDesc: t('items.tableDesc'),\n      blockquote: t('items.blockquote'),\n      blockquoteDesc: t('items.blockquoteDesc'),\n      codeBlock: t('items.codeBlock'),\n      codeBlockDesc: t('items.codeBlockDesc'),\n      divider: t('items.divider'),\n      dividerDesc: t('items.dividerDesc'),\n      inlineMath: t('items.inlineMath'),\n      inlineMathDesc: t('items.inlineMathDesc'),\n      blockMath: t('items.blockMath'),\n      blockMathDesc: t('items.blockMathDesc'),\n      flowchart: t('items.flowchart'),\n      flowchartDesc: t('items.flowchartDesc'),\n      sequence: t('items.sequence'),\n      sequenceDesc: t('items.sequenceDesc'),\n      gantt: t('items.gantt'),\n      ganttDesc: t('items.ganttDesc'),\n      classDiagram: t('items.classDiagram'),\n      classDiagramDesc: t('items.classDiagramDesc'),\n      stateDiagram: t('items.stateDiagram'),\n      stateDiagramDesc: t('items.stateDiagramDesc'),\n      pie: t('items.pie'),\n      pieDesc: t('items.pieDesc'),\n      erDiagram: t('items.erDiagram'),\n      erDiagramDesc: t('items.erDiagramDesc'),\n      journey: t('items.journey'),\n      journeyDesc: t('items.journeyDesc'),\n    },\n    imageUpload: {\n      success: t('imageUpload.success'),\n      saveSuccess: t('imageUpload.saveSuccess'),\n      savePath: t('imageUpload.savePath'),\n      failed: t('imageUpload.failed'),\n    },\n  }), [t])\n\n  // 分组顺序\n  const groupOrder = useMemo(() => [\n    translations.groups.ai,\n    translations.groups.heading,\n    translations.groups.list,\n    translations.groups.block,\n    translations.groups.align,\n    translations.groups.embed,\n    translations.groups.math,\n    translations.groups.chart,\n  ], [translations.groups])\n\n  const items = useMemo(() => {\n    return filterItems(suggestionItems(translations), query)\n  }, [query, translations])\n\n  const groupedItems = useMemo(() => {\n    const groups: Record<string, SlashCommandItem[]> = {}\n    items.forEach((item) => {\n      if (!groups[item.group]) {\n        groups[item.group] = []\n      }\n      groups[item.group].push(item)\n    })\n    return Object.entries(groups).sort((a, b) => {\n      const orderA = groupOrder.indexOf(a[0])\n      const orderB = groupOrder.indexOf(b[0])\n      if (orderA === -1 && orderB === -1) return 0\n      if (orderA === -1) return 1\n      if (orderB === -1) return -1\n      return orderA - orderB\n    })\n  }, [items])\n\n  const flatItems = useMemo(() => {\n    return groupedItems.flatMap(([, items]) => items)\n  }, [groupedItems])\n\n  useEffect(() => {\n    setSelectedIndex(0)\n  }, [query])\n\n  // Scroll selected item into view when index changes\n  useEffect(() => {\n    const selectedRef = itemRefs.current[selectedIndex]\n    if (selectedRef) {\n      selectedRef.scrollIntoView({\n        behavior: 'smooth',\n        block: 'nearest',\n      })\n    }\n  }, [selectedIndex])\n\n  const selectItem = useCallback(\n    (index: number) => {\n      const item = flatItems[index]\n      if (item) {\n        const { from, to } = editor.state.selection\n        const tr = editor.state.doc\n        let slashStart = from\n        for (let i = from - 1; i >= Math.max(0, from - 20); i--) {\n          const node = tr.nodeAt(i)\n          if (node && node.text && node.text.endsWith('/')) {\n            slashStart = i\n            break\n          }\n          if (node && node.text && !node.text.includes('/')) {\n            break\n          }\n        }\n\n        editor.chain()\n          .focus()\n          .deleteRange({ from: slashStart, to: to })\n          .run()\n\n        item.command({ editor, range: { from: slashStart, to } })\n      }\n    },\n    [editor, flatItems]\n  )\n\n  const upHandler = useCallback(() => {\n    setSelectedIndex((prev) => (prev + flatItems.length - 1) % flatItems.length)\n  }, [flatItems.length])\n\n  const downHandler = useCallback(() => {\n    setSelectedIndex((prev) => (prev + 1) % flatItems.length)\n  }, [flatItems.length])\n\n  const enterHandler = useCallback(() => {\n    selectItem(selectedIndex)\n  }, [selectItem, selectedIndex])\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      onKeyDown: ({ event }: { event: KeyboardEvent }) => {\n        if (event.key === 'ArrowUp') {\n          upHandler()\n          return true\n        }\n        if (event.key === 'ArrowDown') {\n          downHandler()\n          return true\n        }\n        if (event.key === 'Enter') {\n          enterHandler()\n          return true\n        }\n        return false\n      },\n    }),\n    [upHandler, downHandler, enterHandler]\n  )\n\n  if (items.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"max-h-64 overflow-auto p-1 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border border-border rounded-lg shadow-lg min-w-36\">\n      {groupedItems.map(([group, groupItems], groupIdx) => {\n        // 计算当前分组之前的累积偏移量\n        let offset = 0\n        for (let i = 0; i < groupIdx; i++) {\n          offset += groupedItems[i][1].length\n        }\n\n        return (\n          <div key={group}>\n            <div className=\"px-2 py-0.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider\">\n              {group}\n            </div>\n            <div>\n              {groupItems.map((item, itemIdx) => {\n                const flatIndex = offset + itemIdx\n                const isSelected = flatIndex === selectedIndex\n\n                return (\n                  <button\n                    key={item.title}\n                    ref={(el) => {\n                      itemRefs.current[flatIndex] = el\n                    }}\n                    className={cn(\n                      'w-full flex items-center gap-2 px-2 py-1 text-sm rounded-md transition-colors text-left',\n                      isSelected ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-foreground'\n                    )}\n                    onClick={() => selectItem(flatIndex)}\n                    onMouseEnter={() => setSelectedIndex(flatIndex)}\n                  >\n                    <span className=\"shrink-0 w-4 h-4 flex items-center justify-center\">\n                      {item.icon}\n                    </span>\n                    <span className=\"truncate\">{item.title}</span>\n                  </button>\n                )\n              })}\n            </div>\n          </div>\n        )\n      })}\n    </div>\n  )\n})\n\nSlashMenu.displayName = 'SlashMenu'\n\nexport default SlashMenu\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/slash-command/suggestion.tsx",
    "content": "import {\n  Heading1,\n  Heading2,\n  Heading3,\n  List,\n  ListOrdered,\n  CheckSquare,\n  Quote,\n  Code,\n  Table,\n  Minus,\n  Sparkles,\n  Sigma,\n  GitBranch,\n  GitCommit,\n  Calendar,\n  Layers,\n  Activity,\n  PieChart,\n  Database,\n  Map,\n  Image as ImageIcon,\n} from 'lucide-react'\nimport { SuggestionProps } from '@tiptap/suggestion'\nimport { type Editor, type Range } from '@tiptap/core'\nimport { open } from '@tauri-apps/plugin-dialog'\nimport { readFile } from '@tauri-apps/plugin-fs'\nimport { handleImageUpload } from '@/lib/image-handler'\nimport useArticleStore from '@/stores/article'\nimport { toast } from '@/hooks/use-toast'\n\nexport interface SlashCommandItem {\n  title: string\n  description?: string\n  icon: React.ReactNode\n  group: string\n  searchTerms?: string[]\n  command: (props: { editor: Editor; range: Range }) => void\n}\n\n// 辅助函数: 创建 Mermaid 图表命令\nconst createMermaidCommand = (\n  type: 'flowchart' | 'sequence' | 'gantt' | 'classDiagram' | 'stateDiagram' | 'pie' | 'er' | 'journey'\n) => ({\n  command: ({ editor, range }: { editor: Editor; range: Range }) => {\n    editor.chain().focus().deleteRange(range).run()\n    const event = new CustomEvent('tiptap-insert-mermaid', {\n      detail: { type },\n    })\n    document.dispatchEvent(event)\n  },\n})\n\n// 辅助函数: 创建自定义事件命令\nconst createCustomEventCommand = (eventName: string, detail?: any) => ({\n  command: ({ editor, range }: { editor: Editor; range: Range }) => {\n    editor.chain().focus().deleteRange(range).run()\n    const event = new CustomEvent(eventName, { detail })\n    document.dispatchEvent(event)\n  },\n})\n\n// 翻译接口\nexport interface SlashCommandTranslations {\n  groups: {\n    ai: string\n    heading: string\n    list: string\n    block: string\n    align: string\n    embed: string\n    math: string\n    chart: string\n  }\n  items: {\n    continue: string\n    continueDesc: string\n    heading1: string\n    heading1Desc: string\n    heading2: string\n    heading2Desc: string\n    heading3: string\n    heading3Desc: string\n    bulletList: string\n    bulletListDesc: string\n    orderedList: string\n    orderedListDesc: string\n    taskList: string\n    taskListDesc: string\n    image: string\n    imageDesc: string\n    table: string\n    tableDesc: string\n    blockquote: string\n    blockquoteDesc: string\n    codeBlock: string\n    codeBlockDesc: string\n    divider: string\n    dividerDesc: string\n    inlineMath: string\n    inlineMathDesc: string\n    blockMath: string\n    blockMathDesc: string\n    flowchart: string\n    flowchartDesc: string\n    sequence: string\n    sequenceDesc: string\n    gantt: string\n    ganttDesc: string\n    classDiagram: string\n    classDiagramDesc: string\n    stateDiagram: string\n    stateDiagramDesc: string\n    pie: string\n    pieDesc: string\n    erDiagram: string\n    erDiagramDesc: string\n    journey: string\n    journeyDesc: string\n  }\n  imageUpload: {\n    success: string\n    saveSuccess: string\n    savePath: string\n    failed: string\n  }\n}\n\n// 导出搜索函数供外部使用\nexport function filterItems(items: SlashCommandItem[], query: string): SlashCommandItem[] {\n  if (!query || query.length === 0) {\n    return items\n  }\n  const search = query.toLowerCase()\n  return items.filter(\n    (item) =>\n      item.title.toLowerCase().includes(search) ||\n      item.searchTerms?.some((term) => term.toLowerCase().includes(search)) ||\n      item.description?.toLowerCase().includes(search)\n  )\n}\n\nexport const suggestionItems = (t?: SlashCommandTranslations): SlashCommandItem[] => {\n  // 默认中文翻译（作为后备）\n  const defaultT: SlashCommandTranslations = {\n    groups: {\n      ai: 'AI',\n      heading: '标题',\n      list: '列表',\n      block: '块级',\n      align: '对齐',\n      embed: '嵌入',\n      math: '数学',\n      chart: '图表',\n    },\n    items: {\n      continue: '续写',\n      continueDesc: 'AI 续写内容',\n      heading1: '标题1',\n      heading1Desc: '大标题',\n      heading2: '标题2',\n      heading2Desc: '中标题',\n      heading3: '标题3',\n      heading3Desc: '小标题',\n      bulletList: '无序列表',\n      bulletListDesc: '创建简单的项目列表',\n      orderedList: '有序列表',\n      orderedListDesc: '创建带编号的列表',\n      taskList: '任务列表',\n      taskListDesc: '创建带复选框的任务列表',\n      image: '图片',\n      imageDesc: '插入本地图片或图床图片',\n      table: '表格',\n      tableDesc: '插入表格',\n      blockquote: '引用',\n      blockquoteDesc: '捕获引用内容',\n      codeBlock: '代码块',\n      codeBlockDesc: '捕获代码片段',\n      divider: '分割线',\n      dividerDesc: '在元素之间创建分隔线',\n      inlineMath: '行内公式',\n      inlineMathDesc: '插入行内 LaTeX 公式',\n      blockMath: '块级公式',\n      blockMathDesc: '插入块级 LaTeX 公式',\n      flowchart: '流程图',\n      flowchartDesc: '插入流程图',\n      sequence: '时序图',\n      sequenceDesc: '插入时序图',\n      gantt: '甘特图',\n      ganttDesc: '插入甘特图',\n      classDiagram: '类图',\n      classDiagramDesc: '插入类图',\n      stateDiagram: '状态图',\n      stateDiagramDesc: '插入状态图',\n      pie: '饼图',\n      pieDesc: '插入饼图',\n      erDiagram: 'ER图',\n      erDiagramDesc: '插入实体关系图',\n      journey: '旅程图',\n      journeyDesc: '插入用户旅程图',\n    },\n    imageUpload: {\n      success: '上传成功',\n      saveSuccess: '保存成功',\n      savePath: '保存路径: __PATH__',\n      failed: '插入图片失败',\n    },\n  }\n\n  const tr = t || defaultT\n\n  const items: SlashCommandItem[] = [\n    // AI\n    {\n      title: tr.items.continue,\n      description: tr.items.continueDesc,\n      icon: <Sparkles className=\"w-4 h-4\" />,\n      group: tr.groups.ai,\n      searchTerms: ['ai', 'continue', 'write', 'completion'],\n      ...createCustomEventCommand('tiptap-ai-continue'),\n    },\n    {\n      title: tr.items.heading1,\n      description: tr.items.heading1Desc,\n      icon: <Heading1 className=\"w-4 h-4\" />,\n      group: tr.groups.heading,\n      searchTerms: ['heading', 'h1', 'header'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run()\n      },\n    },\n    {\n      title: tr.items.heading2,\n      description: tr.items.heading2Desc,\n      icon: <Heading2 className=\"w-4 h-4\" />,\n      group: tr.groups.heading,\n      searchTerms: ['heading', 'h2', 'header'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run()\n      },\n    },\n    {\n      title: tr.items.heading3,\n      description: tr.items.heading3Desc,\n      icon: <Heading3 className=\"w-4 h-4\" />,\n      group: tr.groups.heading,\n      searchTerms: ['heading', 'h3', 'header'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run()\n      },\n    },\n\n    // 列表\n    {\n      title: tr.items.bulletList,\n      description: tr.items.bulletListDesc,\n      icon: <List className=\"w-4 h-4\" />,\n      group: tr.groups.list,\n      searchTerms: ['bullet', 'ul', 'list'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).toggleBulletList().run()\n      },\n    },\n    {\n      title: tr.items.orderedList,\n      description: tr.items.orderedListDesc,\n      icon: <ListOrdered className=\"w-4 h-4\" />,\n      group: tr.groups.list,\n      searchTerms: ['ordered', 'ol', 'numbered', 'list'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).toggleOrderedList().run()\n      },\n    },\n    {\n      title: tr.items.taskList,\n      description: tr.items.taskListDesc,\n      icon: <CheckSquare className=\"w-4 h-4\" />,\n      group: tr.groups.list,\n      searchTerms: ['task', 'todo', 'checkbox', 'checklist'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).toggleTaskList().run()\n      },\n    },\n\n    // 块级元素\n    {\n      title: tr.items.image,\n      description: tr.items.imageDesc,\n      icon: (\n        <span aria-hidden=\"true\">\n          <ImageIcon className=\"w-4 h-4\" />\n        </span>\n      ),\n      group: tr.groups.block,\n      searchTerms: ['image', 'picture', 'photo', 'img'],\n      command: async ({ editor, range }: { editor: Editor; range: Range }) => {\n        const rangeStart = range.from\n\n        // Insert \"Uploading...\" text as placeholder\n        editor.chain().focus().deleteRange(range).insertContentAt(rangeStart, {\n          type: 'text',\n          text: 'Uploading... ',\n        }).run()\n\n        // Get the position range of the placeholder\n        const placeholderStart = rangeStart\n        const placeholderEnd = rangeStart + 'Uploading... '.length\n\n        try {\n          const file = await open({\n            multiple: false,\n            filters: [\n              {\n                name: 'Images',\n                extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'],\n              },\n            ],\n          })\n\n          if (!file) {\n            // User cancelled, remove placeholder\n            editor.chain().focus().deleteRange({ from: placeholderStart, to: placeholderEnd }).run()\n            return\n          }\n\n          const activeFilePath = useArticleStore.getState().activeFilePath\n          // open 返回的是文件路径字符串，需要读取文件内容并转换为 File 对象\n          let fileObj: File\n          if (typeof file === 'string') {\n            const fileData = await readFile(file)\n            const ext = file.split('.').pop() || 'png'\n            const fileName = file.split('/').pop() || `image.${ext}`\n            // 创建 ArrayBuffer 副本以避免类型问题\n            const arrayBuffer = new Uint8Array(fileData).buffer\n            fileObj = new File([arrayBuffer], fileName, { type: `image/${ext}` })\n          } else {\n            fileObj = file\n          }\n\n          const result = await handleImageUpload(fileObj, activeFilePath)\n\n          // Delete the placeholder text\n          editor.chain().focus().deleteRange({ from: placeholderStart, to: placeholderEnd }).run()\n\n          // Insert the actual image\n          editor.chain().focus().insertContentAt(placeholderStart, {\n            type: 'image',\n            attrs: {\n              src: result.src,\n              alt: fileObj.name,\n              relativeSrc: result.relativePath,\n            },\n          }).run()\n        } catch (error) {\n          // Remove the placeholder on error\n          editor.chain().focus().deleteRange({ from: placeholderStart, to: placeholderEnd }).run()\n\n          toast({\n            title: tr.imageUpload.failed,\n            description: error instanceof Error ? error.message : 'Unknown error',\n            variant: 'destructive',\n          })\n        }\n      },\n    },\n    {\n      title: tr.items.table,\n      description: tr.items.tableDesc,\n      icon: <Table className=\"w-4 h-4\" />,\n      group: tr.groups.block,\n      searchTerms: ['table', 'grid', 'matrix'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()\n      },\n    },\n    {\n      title: tr.items.blockquote,\n      description: tr.items.blockquoteDesc,\n      icon: <Quote className=\"w-4 h-4\" />,\n      group: tr.groups.block,\n      searchTerms: ['blockquote', 'quote', 'citation'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).toggleBlockquote().run()\n      },\n    },\n    {\n      title: tr.items.codeBlock,\n      description: tr.items.codeBlockDesc,\n      icon: <Code className=\"w-4 h-4\" />,\n      group: tr.groups.block,\n      searchTerms: ['code', 'pre', 'programming'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).toggleCodeBlock().run()\n      },\n    },\n    {\n      title: tr.items.divider,\n      description: tr.items.dividerDesc,\n      icon: <Minus className=\"w-4 h-4\" />,\n      group: tr.groups.block,\n      searchTerms: ['hr', 'horizontal', 'divider', 'line'],\n      command: ({ editor, range }: { editor: Editor; range: Range }) => {\n        editor.chain().focus().deleteRange(range).setHorizontalRule().run()\n      },\n    },\n\n    // 数学公式\n    {\n      title: tr.items.inlineMath,\n      description: tr.items.inlineMathDesc,\n      icon: <Sigma className=\"w-4 h-4\" />,\n      group: tr.groups.math,\n      searchTerms: ['math', 'inline', 'latex', 'formula', 'inline-math'],\n      ...createCustomEventCommand('tiptap-insert-inline-math'),\n    },\n    {\n      title: tr.items.blockMath,\n      description: tr.items.blockMathDesc,\n      icon: <Sigma className=\"w-4 h-4\" />,\n      group: tr.groups.math,\n      searchTerms: ['math', 'block', 'latex', 'formula', 'block-math', 'display'],\n      ...createCustomEventCommand('tiptap-insert-block-math'),\n    },\n\n    // 图表\n    {\n      title: tr.items.flowchart,\n      description: tr.items.flowchartDesc,\n      icon: <GitBranch className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'flowchart', 'diagram'],\n      ...createMermaidCommand('flowchart'),\n    },\n    {\n      title: tr.items.sequence,\n      description: tr.items.sequenceDesc,\n      icon: <GitCommit className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'sequence', 'sequenceDiagram'],\n      ...createMermaidCommand('sequence'),\n    },\n    {\n      title: tr.items.gantt,\n      description: tr.items.ganttDesc,\n      icon: <Calendar className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'gantt'],\n      ...createMermaidCommand('gantt'),\n    },\n    {\n      title: tr.items.classDiagram,\n      description: tr.items.classDiagramDesc,\n      icon: <Layers className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'class', 'classDiagram'],\n      ...createMermaidCommand('classDiagram'),\n    },\n    {\n      title: tr.items.stateDiagram,\n      description: tr.items.stateDiagramDesc,\n      icon: <Activity className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'state', 'stateDiagram'],\n      ...createMermaidCommand('stateDiagram'),\n    },\n    {\n      title: tr.items.pie,\n      description: tr.items.pieDesc,\n      icon: <PieChart className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'pie', 'chart'],\n      ...createMermaidCommand('pie'),\n    },\n    {\n      title: tr.items.erDiagram,\n      description: tr.items.erDiagramDesc,\n      icon: <Database className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'er', 'erDiagram'],\n      ...createMermaidCommand('er'),\n    },\n    {\n      title: tr.items.journey,\n      description: tr.items.journeyDesc,\n      icon: <Map className=\"w-4 h-4\" />,\n      group: tr.groups.chart,\n      searchTerms: ['mermaid', 'journey'],\n      ...createMermaidCommand('journey'),\n    },\n  ]\n\n  return items\n}\n\n// Simple slash match function - hardcoded to match \"/\"\nfunction findSlashMatch(config: {\n  char: string\n  allowSpaces: boolean\n  allowedPrefixes: string[] | null\n  startOfLine: boolean\n  $position: any\n}) {\n  const { $position } = config\n  const $pos = $position\n\n  // Check if we're at the start of a text node or have text directly before position\n  const nodeBefore = $pos.nodeBefore\n  const text = nodeBefore?.isText && nodeBefore.text\n\n  if (!text) {\n    return null\n  }\n\n  const textFrom = $pos.pos - text.length\n  const slashIndex = text.lastIndexOf('/')\n\n  if (slashIndex === -1) {\n    return null\n  }\n\n  const from = textFrom + slashIndex\n  const to = $pos.pos\n\n  return {\n    range: { from, to },\n    query: text.slice(slashIndex + 1),\n    text: text.slice(slashIndex),\n  }\n}\n\nexport { findSlashMatch }\n\n// Global callback for menu keyboard handling\nlet menuKeyDownHandler: ((props: { event: KeyboardEvent }) => boolean) | null = null\n\nexport function setMenuKeyDownHandler(handler: ((props: { event: KeyboardEvent }) => boolean) | null) {\n  menuKeyDownHandler = handler\n}\n\nexport const suggestionOptions = {\n  items: ({ query }: { query: string }) => {\n    return filterItems(suggestionItems(), query)\n  },\n\n  render: () => {\n    return {\n      onStart: (props: SuggestionProps) => {\n        const rect = props.clientRect\n        const clientRect = typeof rect === 'function' ? rect() : rect\n        if (!clientRect) {\n          return\n        }\n\n        const editor = props.editor\n        if (!editor) {\n          return\n        }\n\n        const event = new CustomEvent('slash-command-show', {\n          detail: {\n            editor,\n            clientRect,\n            query: props.query || '',\n          },\n        })\n        document.dispatchEvent(event)\n      },\n\n      onUpdate: (props: SuggestionProps) => {\n        const rect = props.clientRect\n        const clientRect = typeof rect === 'function' ? rect() : rect\n        if (!clientRect) {\n          return\n        }\n\n        const event = new CustomEvent('slash-command-update', {\n          detail: {\n            clientRect,\n            query: props.query || '',\n          },\n        })\n        document.dispatchEvent(event)\n      },\n\n      onKeyDown: (props: { event: KeyboardEvent }) => {\n        // Call menu's keyDown handler first\n        if (menuKeyDownHandler) {\n          if (menuKeyDownHandler(props)) {\n            return true\n          }\n        }\n\n        if (props.event.key === 'Escape') {\n          const hideEvent = new CustomEvent('slash-command-hide')\n          document.dispatchEvent(hideEvent)\n          return true\n        }\n\n        return false\n      },\n\n      onExit: () => {\n        const event = new CustomEvent('slash-command-hide')\n        document.dispatchEvent(event)\n      },\n    }\n  },\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/style.css",
    "content": "/* Markdown Editor Styles */\n\n/* ========================\n   Tiptap 编辑器核心样式\n   ======================== */\n\n.tiptap-editor {\n  width: 100%;\n  height: 100%;\n  position: relative;\n}\n\n.tiptap-editor .ProseMirror {\n  outline: none;\n  min-height: 100%;\n  padding: 24px 16px;\n  font-size: 16px;\n  line-height: 1.7;\n  color: hsl(var(--foreground));\n}\n\n.tiptap-editor .ProseMirror p.is-empty::before {\n  content: attr(data-placeholder);\n  float: left;\n  color: hsl(var(--muted-foreground) / 0.5);\n  pointer-events: none;\n  height: 0;\n}\n\n/* 基础样式覆盖 */\n.tiptap-editor .ProseMirror h1 {\n  font-size: 2em;\n  font-weight: 700;\n  margin-top: 1em;\n  margin-bottom: 0.5em;\n}\n\n.tiptap-editor .ProseMirror h2 {\n  font-size: 1.5em;\n  font-weight: 600;\n  margin-top: 1em;\n  margin-bottom: 0.5em;\n}\n\n.tiptap-editor .ProseMirror h3 {\n  font-size: 1.25em;\n  font-weight: 600;\n  margin-top: 1em;\n  margin-bottom: 0.5em;\n}\n\n.tiptap-editor .ProseMirror h4 {\n  font-size: 1em;\n  font-weight: 600;\n  margin-top: 1em;\n  margin-bottom: 0.5em;\n}\n\n/* 粗体和斜体 */\n.tiptap-editor .ProseMirror strong {\n  font-weight: 700;\n}\n\n.tiptap-editor .ProseMirror em {\n  font-style: italic;\n}\n\n.tiptap-editor .ProseMirror del,\n.tiptap-editor .ProseMirror s {\n  text-decoration: line-through;\n}\n\n/* 代码样式 */\n.tiptap-editor .ProseMirror code {\n  background-color: hsl(var(--muted) / 0.5);\n  color: hsl(var(--foreground));\n  padding: 0.2em 0.4em;\n  border-radius: 4px;\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;\n  font-size: 0.9em;\n}\n\n/* 代码块 */\n.tiptap-editor .ProseMirror pre {\n  background-color: hsl(var(--muted) / 0.5);\n  border-radius: 8px;\n  padding: 16px;\n  overflow-x: auto;\n  margin: 1em 0;\n}\n\n.tiptap-editor .ProseMirror pre code {\n  background-color: transparent;\n  padding: 0;\n  font-size: 14px;\n}\n\n/* 代码高亮 */\n.tiptap-editor .ProseMirror pre code .hljs-comment,\n.tiptap-editor .ProseMirror pre code .hljs-quote {\n  color: #6a737d;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-variable,\n.tiptap-editor .ProseMirror pre code .hljs-template-variable,\n.tiptap-editor .ProseMirror pre code .hljs-tag,\n.tiptap-editor .ProseMirror pre code .hljs-name,\n.tiptap-editor .ProseMirror pre code .hljs-selector-id,\n.tiptap-editor .ProseMirror pre code .hljs-selector-class,\n.tiptap-editor .ProseMirror pre code .hljs-regexp,\n.tiptap-editor .ProseMirror pre code .hljs-deletion {\n  color: #f97583;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-number,\n.tiptap-editor .ProseMirror pre code .hljs-built_in,\n.tiptap-editor .ProseMirror pre code .hljs-literal,\n.tiptap-editor .ProseMirror pre code .hljs-type,\n.tiptap-editor .ProseMirror pre code .hljs-params,\n.tiptap-editor .ProseMirror pre code .hljs-meta,\n.tiptap-editor .ProseMirror pre code .hljs-link {\n  color: #0366d6;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-attribute {\n  color: #6f42c1;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-string,\n.tiptap-editor .ProseMirror pre code .hljs-symbol,\n.tiptap-editor .ProseMirror pre code .hljs-bullet,\n.tiptap-editor .ProseMirror pre code .hljs-addition {\n  color: #032f62;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-title,\n.tiptap-editor .ProseMirror pre code .hljs-section {\n  color: #6f42c1;\n}\n\n.tiptap-editor .ProseMirror pre code .hljs-keyword,\n.tiptap-editor .ProseMirror pre code .hljs-selector-tag {\n  color: #d73a49;\n}\n\n/* 引用块 */\n.tiptap-editor .ProseMirror blockquote {\n  border-left: 4px solid hsl(var(--border));\n  padding-left: 16px;\n  margin: 1em 0;\n  color: hsl(var(--muted-foreground));\n  font-style: italic;\n}\n\n/* 分隔线 */\n.tiptap-editor .ProseMirror hr {\n  border: none;\n  border-top: 2px solid hsl(var(--border));\n  margin: 1.5em 0;\n}\n\n/* 链接 */\n.tiptap-editor .ProseMirror a {\n  color: hsl(var(--primary));\n  text-decoration: underline;\n  cursor: pointer;\n}\n\n.tiptap-editor .ProseMirror a:hover {\n  opacity: 0.8;\n}\n\n/* 图片 */\n.tiptap-editor .ProseMirror img {\n  max-width: 100%;\n  height: auto;\n  border-radius: 8px;\n  margin: 1em 0;\n}\n\n/* ========================\n   图片上传样式\n   ======================== */\n\n/* 上传失败的图片 */\n.tiptap-editor .ProseMirror img[data-upload-status=\"error\"] {\n  border: 2px solid hsl(var(--destructive));\n  opacity: 0.8;\n}\n\n/* 上传占位符 */\n.tiptap-editor .ProseMirror .image-upload-placeholder {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  padding: 16px 24px;\n  background-color: hsl(var(--muted) / 0.5);\n  border: 2px dashed hsl(var(--border));\n  border-radius: 8px;\n  color: hsl(var(--muted-foreground));\n  font-size: 14px;\n  min-width: 120px;\n  justify-content: center;\n}\n\n.tiptap-editor .ProseMirror .image-upload-placeholder::before {\n  content: '';\n  width: 20px;\n  height: 20px;\n  border: 2px solid hsl(var(--muted-foreground) / 0.5);\n  border-top-color: hsl(var(--primary));\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* 拖拽上传提示 */\n.tiptap-editor .ProseMirror .drag-over {\n  outline: 3px dashed hsl(var(--primary));\n  outline-offset: 4px;\n}\n\n/* ========================\n   表格样式\n   ======================== */\n\n/* 表格容器 */\n.tiptap-editor .ProseMirror .tableWrapper {\n  margin: 1em 0;\n  overflow-x: auto;\n}\n\n.tiptap-editor .ProseMirror table {\n  border-collapse: collapse;\n  width: 100%;\n  margin: 0;\n  table-layout: fixed;\n}\n\n.tiptap-editor .ProseMirror table th,\n.tiptap-editor .ProseMirror table td {\n  border: 1px solid hsl(var(--border));\n  padding: 8px 12px;\n  text-align: left;\n  vertical-align: top;\n  min-width: 100px;\n  empty-cells: hide;\n}\n\n/* 表格空单元格使用空格替代 nbsp */\n/* TipTap 空单元格结构为 <td><p></p></td> */\n.tiptap-editor .ProseMirror table td:has(p:empty)::before,\n.tiptap-editor .ProseMirror table th:has(p:empty)::before {\n  content: \" \";\n  visibility: hidden;\n}\n\n.tiptap-editor .ProseMirror table th {\n  background-color: hsl(var(--muted) / 0.3);\n  font-weight: 600;\n}\n\n.tiptap-editor .ProseMirror table tr:nth-child(2n) {\n  background-color: hsl(var(--muted) / 0.15);\n}\n\n/* 选中单元格 */\n.tiptap-editor .ProseMirror .selectedCell {\n  background-color: hsl(var(--primary) / 0.15) !important;\n}\n\n/* 表格头部样式 */\n.tiptap-editor .ProseMirror table thead tr:first-child th {\n  background-color: hsl(var(--muted) / 0.4);\n}\n\n/* 列对齐样式 */\n.tiptap-editor .ProseMirror table td[data-align=\"center\"],\n.tiptap-editor .ProseMirror table th[data-align=\"center\"] {\n  text-align: center;\n}\n\n.tiptap-editor .ProseMirror table td[data-align=\"right\"],\n.tiptap-editor .ProseMirror table th[data-align=\"right\"] {\n  text-align: right;\n}\n\n.tiptap-editor .ProseMirror table td[data-align=\"left\"],\n.tiptap-editor .ProseMirror table th[data-align=\"left\"] {\n  text-align: left;\n}\n\n/* 表格调整大小手柄 */\n.tiptap-editor .ProseMirror .tableResizeHandle {\n  position: absolute;\n  right: -4px;\n  top: 0;\n  bottom: 0;\n  width: 8px;\n  cursor: col-resize;\n  background-color: transparent;\n}\n\n.tiptap-editor .ProseMirror .tableResizeHandle:hover {\n  background-color: hsl(var(--primary) / 0.5);\n}\n\n.tiptap-editor .ProseMirror .tableResizeHandle::before {\n  content: '';\n  position: absolute;\n  left: 50%;\n  top: 50%;\n  transform: translate(-50%, -50%);\n  width: 4px;\n  height: 20px;\n  background-color: hsl(var(--primary) / 0.3);\n  border-radius: 2px;\n}\n\n/* 表格工具栏样式 */\n.table-toolbar {\n  display: flex;\n  align-items: center;\n  padding: 8px 12px;\n  background-color: hsl(var(--muted) / 0.3);\n  border-bottom: 1px solid hsl(var(--border));\n  flex-shrink: 0;\n}\n\n.table-toolbar button {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  border: none;\n  background: transparent;\n  border-radius: 6px;\n  cursor: pointer;\n  color: hsl(var(--foreground));\n  transition: all 0.15s ease;\n}\n\n.table-toolbar button:hover {\n  background-color: hsl(var(--accent));\n}\n\n.table-toolbar button:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n/* 暗色模式下的表格样式 */\n.dark .tiptap-editor .ProseMirror table th {\n  background-color: hsl(var(--muted) / 0.2);\n}\n\n.dark .tiptap-editor .ProseMirror table thead tr:first-child th {\n  background-color: hsl(var(--muted) / 0.3);\n}\n\n.dark .tiptap-editor .ProseMirror table tr:nth-child(2n) {\n  background-color: hsl(var(--muted) / 0.1);\n}\n\n.dark .tiptap-editor .ProseMirror .selectedCell {\n  background-color: hsl(var(--primary) / 0.25) !important;\n}\n\n/* 任务列表 */\n.tiptap-editor .ProseMirror table {\n  border-collapse: collapse;\n  width: 100%;\n  margin: 1em 0;\n}\n\n.tiptap-editor .ProseMirror table th,\n.tiptap-editor .ProseMirror table td {\n  border: 1px solid hsl(var(--border));\n  padding: 8px 12px;\n  text-align: left;\n}\n\n.tiptap-editor .ProseMirror table th {\n  background-color: hsl(var(--muted) / 0.3);\n  font-weight: 600;\n}\n\n.tiptap-editor .ProseMirror table tr:nth-child(2n) {\n  background-color: hsl(var(--muted) / 0.2);\n}\n\n/* 列表 */\n.tiptap-editor .ProseMirror ul,\n.tiptap-editor .ProseMirror ol {\n  padding-left: 1.5em;\n  margin: 0.5em 0;\n}\n\n.tiptap-editor .ProseMirror ul {\n  list-style-type: disc;\n}\n\n.tiptap-editor .ProseMirror ol {\n  list-style-type: decimal;\n}\n\n.tiptap-editor .ProseMirror li {\n  margin: 0.25em 0;\n}\n\n/* 任务列表 */\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] {\n  list-style: none;\n  padding-left: 0;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  line-height: 1.7;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  user-select: none;\n  cursor: pointer;\n  width: 16px;\n  height: 16px;\n  min-width: 16px;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"],\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label > span {\n  appearance: none;\n  -webkit-appearance: none;\n  width: 16px !important;\n  height: 16px !important;\n  min-width: 16px !important;\n  border: 1px solid hsl(var(--border)) !important;\n  border-radius: 3px !important;\n  background-color: transparent !important;\n  cursor: pointer !important;\n  display: inline-flex !important;\n  align-items: center !important;\n  justify-content: center !important;\n  transition: all 0.15s ease !important;\n  margin: 0 !important;\n  padding: 0 !important;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label > span {\n  display: none !important;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"]:hover,\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label > span:hover {\n  border-color: hsl(var(--primary)) !important;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"]:checked,\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li[data-checked=\"true\"] > label > span {\n  background-color: hsl(var(--primary)) !important;\n  border-color: hsl(var(--primary)) !important;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"]::before,\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label > span::before {\n  content: '';\n  display: none;\n  width: 10px;\n  height: 10px;\n  border-radius: 1px;\n  background-color: transparent;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"]:checked::before,\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > label > span::before {\n  display: block;\n  /* 绘制勾选标记 */\n  width: 6px;\n  height: 10px;\n  background-color: transparent;\n  border: solid hsl(var(--primary-foreground));\n  border-width: 0 2px 2px 0;\n  border-radius: 1px;\n  transform: rotate(45deg);\n  margin-top: -2px;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li[data-checked=\"true\"] > div {\n  text-decoration: line-through;\n  color: hsl(var(--muted-foreground));\n}\n\n/* 任务列表内容区域 */\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] li > div {\n  flex: 1;\n}\n\n/* 嵌套任务列表 */\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] ul[data-type=\"taskList\"] {\n  margin-left: 0;\n}\n\n.tiptap-editor .ProseMirror ul[data-type=\"taskList\"] ul[data-type=\"taskList\"] li {\n  margin-left: 24px;\n}\n\n/* 高亮 */\n.tiptap-editor .ProseMirror mark {\n  background-color: hsl(var(--primary) / 0.3);\n  padding: 0 2px;\n  border-radius: 2px;\n}\n\n/* 下划线 */\n.tiptap-editor .ProseMirror u {\n  text-decoration: underline;\n}\n\n/* 删除线 */\n.tiptap-editor .ProseMirror s {\n  text-decoration: line-through;\n}\n\n/* 引用标记 */\n.tiptap-editor .ProseMirror .tiptap-quote-mark,\n.tiptap-quote-mark {\n  border: 2px solid currentColor !important;\n  border-radius: 4px !important;\n  padding: 1px 4px;\n  background: hsl(var(--primary) / 0.12);\n  box-shadow: 0 0 0 1px hsl(var(--primary) / 0.1);\n  transition: border-color 0.2s ease;\n}\n\n/* 浮动工具栏 */\n.tiptap-bubble-menu {\n  z-index: 50;\n}\n\n/* 搜索高亮 */\n.tiptap-search-highlight {\n  background-color: #ffeb3b !important;\n  padding: 0 2px !important;\n  border-radius: 2px !important;\n}\n\n.tiptap-search-highlight-current {\n  background-color: #ff9632 !important;\n  color: white !important;\n  padding: 0 2px !important;\n  border-radius: 2px !important;\n}\n\n/* 搜索替换插件的高亮 - 在 globals.css 中定义 */\n\n/* 选中文本时的背景 */\n.ProseMirror-focused::selection {\n  background-color: hsl(var(--primary) / 0.3);\n}\n\n.tiptap-editor .ProseMirror ::selection {\n  background-color: hsl(var(--primary) / 0.24);\n}\n\n/* 暗色模式适配 */\n.dark .tiptap-editor .ProseMirror code {\n  background-color: hsl(var(--muted) / 0.3);\n}\n\n.dark .tiptap-editor .ProseMirror pre {\n  background-color: hsl(var(--muted) / 0.3);\n}\n\n.dark .tiptap-editor .ProseMirror blockquote {\n  color: hsl(var(--muted-foreground));\n}\n\n.dark .tiptap-editor .ProseMirror table th {\n  background-color: hsl(var(--muted) / 0.2);\n}\n\n/* Placeholder 样式 */\n.tiptap-placeholder {\n  position: absolute;\n  pointer-events: none;\n  color: hsl(var(--muted-foreground) / 0.5);\n}\n\n/* Bubble Menu 容器 */\n.bubble-menu-container {\n  /* Tiptap BubbleMenu 会在运行时自动定位 */\n}\n\n/* AI 子菜单动画 */\n@keyframes slideIn {\n  from {\n    opacity: 0;\n    transform: translateY(-4px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.bubble-menu-container .relative:hover > div {\n  animation: slideIn 0.15s ease-out;\n}\n\n/* ========================\n   面板布局样式\n   ======================== */\n\n/* 优化面板分隔条的隐藏动画 */\n[data-panel-handle] {\n  transition: opacity 0.2s ease-in-out;\n}\n\n[data-panel-handle].hidden {\n  opacity: 0;\n  pointer-events: none;\n}\n\n/* 确保面板折叠时的平滑过渡 */\n[data-resizable-panel] {\n  transition: flex 0.2s ease-in-out;\n}\n\n/* ========================\n   TabBar 标签页样式\n   ======================== */\n\n/* 隐藏滚动条的 TabBar 容器 */\n.scrollbar-hide {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n.scrollbar-hide::-webkit-scrollbar {\n  display: none;\n}\n\n/* 标签页容器 */\n.tab-bar {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 8px;\n  background-color: hsl(var(--muted) / 0.5);\n  border-bottom: 1px solid hsl(var(--border));\n  overflow-x: auto;\n  flex-shrink: 0;\n}\n\n/* 单个标签页 */\n.tab {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 12px;\n  font-size: 13px;\n  border-radius: 6px 6px 0 0;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  white-space: nowrap;\n  max-width: 160px;\n  flex-shrink: 0;\n}\n\n.tab:hover {\n  background-color: hsl(var(--accent));\n}\n\n.tab.active {\n  background-color: hsl(var(--background));\n  border: 1px solid hsl(var(--border));\n  border-bottom: none;\n}\n\n.tab-name {\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* 标签页关闭按钮 */\n.tab-close {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 16px;\n  height: 16px;\n  border-radius: 4px;\n  opacity: 0;\n  transition: all 0.15s ease;\n}\n\n.tab:hover .tab-close {\n  opacity: 1;\n}\n\n.tab-close:hover {\n  background-color: hsl(var(--muted-foreground) / 0.2);\n}\n\n/* 修改指示器 */\n.tab-modified {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background-color: hsl(var(--primary));\n}\n\n/* 新建标签页按钮 */\n.new-tab-button {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 4px 8px;\n  color: hsl(var(--muted-foreground));\n  border-radius: 4px;\n  transition: all 0.15s ease;\n  flex-shrink: 0;\n}\n\n.new-tab-button:hover {\n  color: hsl(var(--foreground));\n  background-color: hsl(var(--accent));\n}\n\n/* 暗色模式适配 */\n.dark .tab-bar {\n  background-color: hsl(var(--muted) / 0.3);\n}\n\n.dark .tab:hover {\n  background-color: hsl(var(--accent) / 0.5);\n}\n\n.dark .tab.active {\n  background-color: hsl(var(--background));\n}\n\n/* 移动端工具栏支持左右滑动 */\n@media (max-width: 768px) {\n  #mobile-writing .mobile-editor-context-bar {\n    position: sticky;\n    top: 0;\n    z-index: 35;\n  }\n\n  /* 确保移动端写作页面的编辑器容器不会阻止滑动 */\n  #mobile-writing .tiptap-editor .ProseMirror {\n    touch-action: pan-x pan-y !important;\n    -webkit-overflow-scrolling: touch !important;\n  }\n\n  #mobile-writing .tiptap-editor .ProseMirror ::selection {\n    background-color: hsl(var(--primary) / 0.34);\n  }\n\n  /* 移动端优化 FooterBar */\n  #mobile-writing .tiptap-editor ~ div {\n    height: auto !important;\n    min-height: 44px !important;\n  }\n}\n\n/* ========================\n   Tippy.js 样式\n   ======================== */\n\n.tippy-box {\n  position: relative;\n  display: inline-block;\n  border-radius: 6px;\n  font-size: 14px;\n  line-height: 1.4;\n  white-space: normal;\n  outline: 0;\n  z-index: 100;\n}\n\n.tippy-content {\n  position: relative;\n  padding: 8px 12px;\n  background-color: hsl(var(--background));\n  border: 1px solid hsl(var(--border));\n  border-radius: 8px;\n  box-shadow: 0 10px 15px -3px hsl(var(--foreground) / 0.1), 0 4px 6px -4px hsl(var(--foreground) / 0.1);\n  color: hsl(var(--foreground));\n}\n\n.tippy-arrow {\n  position: absolute;\n  width: 16px;\n  height: 16px;\n  color: hsl(var(--border));\n}\n\n.tippy-box[data-placement^='top'] > .tippy-arrow {\n  bottom: -8px;\n}\n\n.tippy-box[data-placement^='top'] > .tippy-arrow::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 0;\n  transform: rotate(45deg);\n  width: 8px;\n  height: 8px;\n  background-color: hsl(var(--background));\n  border-left: 1px solid hsl(var(--border));\n  border-top: 1px solid hsl(var(--border));\n}\n\n.tippy-box[data-placement^='bottom'] > .tippy-arrow {\n  top: -8px;\n}\n\n.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  bottom: 0;\n  transform: rotate(-135deg);\n  width: 8px;\n  height: 8px;\n  background-color: hsl(var(--background));\n  border-right: 1px solid hsl(var(--border));\n  border-bottom: 1px solid hsl(var(--border));\n}\n\n.tippy-box[data-placement^='left'] > .tippy-arrow {\n  right: -8px;\n}\n\n.tippy-box[data-placement^='left'] > .tippy-arrow::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 0;\n  transform: rotate(135deg);\n  width: 8px;\n  height: 8px;\n  background-color: hsl(var(--background));\n  border-left: 1px solid hsl(var(--border));\n  border-top: 1px solid hsl(var(--border));\n}\n\n.tippy-box[data-placement^='right'] > .tippy-arrow {\n  left: -8px;\n}\n\n.tippy-box[data-placement^='right'] > .tippy-arrow::before {\n  content: '';\n  position: absolute;\n  right: 0;\n  top: 0;\n  transform: rotate(-45deg);\n  width: 8px;\n  height: 8px;\n  background-color: hsl(var(--background));\n  border-right: 1px solid hsl(var(--border));\n  border-bottom: 1px solid hsl(var(--border));\n}\n\n/* 动画 */\n.tippy-box[data-state='hidden'] {\n  opacity: 0;\n  pointer-events: none;\n}\n\n.tippy-box[data-state='visible'] {\n  opacity: 1;\n  pointer-events: auto;\n}\n\n/* 暗色模式 */\n.dark .tippy-content {\n  background-color: hsl(var(--background));\n  color: hsl(var(--foreground));\n}\n\n.dark .tippy-arrow {\n  color: hsl(var(--border));\n}\n\n/* ========================\n   AI 建议样式\n   ======================== */\n\n/* AI 建议标记 - 选中时显示 */\n.tiptap-editor .ProseMirror .ai-suggestion {\n  background-color: hsl(var(--primary) / 0.2);\n  border-bottom: 2px solid hsl(var(--primary));\n  padding: 0 2px;\n  border-radius: 2px;\n  position: relative;\n}\n\n/* AI 建议标记 - 鼠标悬停时显示虚线框 */\n.tiptap-editor .ProseMirror .ai-suggestion:hover {\n  background-color: hsl(var(--primary) / 0.3);\n}\n\n/* AI 建议标记 - 选中状态 */\n.tiptap-editor .ProseMirror .ai-suggestion.is-selected {\n  background-color: hsl(var(--primary) / 0.4);\n  border-bottom-color: hsl(var(--primary));\n}\n\n/* 暗色模式下的 AI 建议样式 */\n.dark .tiptap-editor .ProseMirror .ai-suggestion {\n  background-color: hsl(var(--primary) / 0.3);\n  border-bottom-color: hsl(var(--primary) / 0.8);\n}\n\n.dark .tiptap-editor .ProseMirror .ai-suggestion:hover {\n  background-color: hsl(var(--primary) / 0.4);\n}\n\n/* ========================\n   Mermaid 图表样式\n   ======================== */\n\n.mermaid-diagram-wrapper {\n  position: relative;\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.mermaid-diagram-wrapper .mermaid-preview {\n  min-height: 60px;\n  position: relative;\n}\n\n.mermaid-diagram-wrapper .mermaid-overlay {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  z-index: 10;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg {\n  overflow-x: auto;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg svg {\n  max-width: 100%;\n  height: auto;\n}\n\n.mermaid-diagram-wrapper .mermaid-editor {\n  box-shadow: 0 4px 6px -1px hsl(var(--foreground) / 0.1);\n}\n\n.mermaid-diagram-wrapper textarea {\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;\n}\n\n/* Mermaid SVG 内联样式覆盖 */\n.mermaid-diagram-wrapper .mermaid-svg .label {\n  font-family: inherit !important;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg .node rect,\n.mermaid-diagram-wrapper .mermaid-svg .node circle,\n.mermaid-diagram-wrapper .mermaid-svg .node polygon,\n.mermaid-diagram-wrapper .mermaid-svg .node path {\n  fill: hsl(var(--card));\n  stroke: hsl(var(--border));\n  stroke-width: 1px;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg .edgePath .path {\n  stroke: hsl(var(--foreground));\n  stroke-width: 2px;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg .edgeLabel {\n  background-color: hsl(var(--background));\n}\n\n.mermaid-diagram-wrapper .mermaid-svg text {\n  fill: hsl(var(--foreground));\n  font-family: inherit !important;\n}\n\n.mermaid-diagram-wrapper .mermaid-svg .titleText {\n  fill: hsl(var(--foreground));\n  font-weight: 600;\n}\n\n/* 甘特图样式 */\n.gantt .bar {\n  fill: hsl(var(--primary)) !important;\n}\n\n.gantt .bar-label {\n  fill: hsl(var(--primary-foreground)) !important;\n}\n\n/* 时序图样式 */\n.sequence-diagram .actor {\n  fill: hsl(var(--card)) !important;\n  stroke: hsl(var(--border)) !important;\n}\n\n.sequence-diagram .signal {\n  stroke: hsl(var(--foreground)) !important;\n}\n\n.sequence-diagram .note {\n  fill: hsl(var(--muted)) !important;\n  stroke: hsl(var(--border)) !important;\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/conflict-dialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport { Editor } from '@tiptap/react'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogDescription,\n  DialogFooter,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { pullRemoteFile, saveLocalFile } from '@/lib/sync/auto-sync'\nimport { updateFileSyncTime } from '@/lib/sync/conflict-resolution'\nimport emitter from '@/lib/emitter'\n\ninterface ConflictDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  activeFilePath: string | null\n  editor: Editor\n  onResolved: () => void\n}\n\n// 简单的 diff 计算\nfunction computeDiff(oldText: string, newText: string): { type: 'equal' | 'add' | 'remove', text: string }[] {\n  const oldLines = oldText.split('\\n')\n  const newLines = newText.split('\\n')\n  const result: { type: 'equal' | 'add' | 'remove', text: string }[] = []\n\n  // 简单的行对比\n  const maxLen = Math.max(oldLines.length, newLines.length)\n  let oldIdx = 0\n  let newIdx = 0\n\n  while (oldIdx < maxLen || newIdx < maxLen) {\n    const oldLine = oldLines[oldIdx]\n    const newLine = newLines[newIdx]\n\n    if (oldLine === newLine) {\n      if (oldLine !== undefined) {\n        result.push({ type: 'equal', text: oldLine })\n      }\n      oldIdx++\n      newIdx++\n    } else if (oldLine === undefined) {\n      // 新增行\n      if (newLine !== undefined) {\n        result.push({ type: 'add', text: newLine })\n      }\n      newIdx++\n    } else if (newLine === undefined) {\n      // 删除行\n      result.push({ type: 'remove', text: oldLine })\n      oldIdx++\n    } else {\n      // 不相同，认为是修改\n      result.push({ type: 'remove', text: oldLine })\n      result.push({ type: 'add', text: newLine })\n      oldIdx++\n      newIdx++\n    }\n  }\n\n  return result\n}\n\nexport function ConflictDialog({\n  open,\n  onOpenChange,\n  activeFilePath,\n  editor,\n  onResolved,\n}: ConflictDialogProps) {\n  const [localContent, setLocalContent] = useState('')\n  const [remoteContent, setRemoteContent] = useState('')\n  const [isLoading, setIsLoading] = useState(false)\n  const [diff, setDiff] = useState<{ type: 'equal' | 'add' | 'remove', text: string }[]>([])\n\n  // 当对话框打开时，获取本地和远程内容\n  useEffect(() => {\n    if (!open || !activeFilePath) return\n\n    const fetchContents = async () => {\n      try {\n        // 获取远程内容\n        const remote = await pullRemoteFile(activeFilePath)\n        setRemoteContent(remote)\n\n        // 获取本地内容（从编辑器）\n        const local = editor.getMarkdown()\n        setLocalContent(local)\n\n        // 计算 diff\n        const diffResult = computeDiff(local, remote)\n        setDiff(diffResult)\n      } catch (error) {\n        console.error('Failed to fetch contents for conflict:', error)\n      }\n    }\n\n    fetchContents()\n  }, [open, activeFilePath, editor])\n\n  const handleKeepLocal = async () => {\n    if (!activeFilePath) return\n\n    setIsLoading(true)\n    try {\n      // 保留本地，更新同步时间\n      await updateFileSyncTime(activeFilePath)\n      // 触发事件\n      emitter.emit('sync-pulled', { path: activeFilePath })\n      onResolved()\n      onOpenChange(false)\n    } catch (error) {\n      console.error('Failed to keep local:', error)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const handleKeepRemote = async () => {\n    if (!activeFilePath) return\n\n    setIsLoading(true)\n    try {\n      // 使用远程内容覆盖本地\n      await saveLocalFile(activeFilePath, remoteContent)\n      editor.commands.setContent(remoteContent, { contentType: 'markdown' })\n      await updateFileSyncTime(activeFilePath)\n      emitter.emit('sync-pulled', { path: activeFilePath })\n      onResolved()\n      onOpenChange(false)\n    } catch (error) {\n      console.error('Failed to keep remote:', error)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  // 统计变化\n  const addCount = diff.filter(d => d.type === 'add').length\n  const removeCount = diff.filter(d => d.type === 'remove').length\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-4xl max-h-[80vh] overflow-hidden flex flex-col\">\n        <DialogHeader>\n          <DialogTitle>文件冲突</DialogTitle>\n          <DialogDescription>\n            检测到远程文件与本地文件存在冲突。请选择要保留的版本。\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"flex-1 overflow-hidden flex gap-4 min-h-[300px]\">\n          {/* 本地版本 */}\n          <div className=\"flex-1 flex flex-col overflow-hidden border rounded-md\">\n            <div className=\"bg-muted px-3 py-2 text-sm font-medium border-b\">\n              本地版本\n            </div>\n            <div className=\"flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap\">\n              {localContent || '加载中...'}\n            </div>\n          </div>\n\n          {/* 远程版本 */}\n          <div className=\"flex-1 flex flex-col overflow-hidden border rounded-md\">\n            <div className=\"bg-muted px-3 py-2 text-sm font-medium border-b\">\n              远程版本\n            </div>\n            <div className=\"flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap\">\n              {remoteContent || '加载中...'}\n            </div>\n          </div>\n        </div>\n\n        {/* 变化统计 */}\n        <div className=\"text-sm text-muted-foreground\">\n          <span className=\"text-green-500\">+{addCount} 新增</span>\n          {' / '}\n          <span className=\"text-red-500\">-{removeCount} 删除</span>\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={handleKeepLocal}\n            disabled={isLoading}\n          >\n            保留本地\n          </Button>\n          <Button\n            onClick={handleKeepRemote}\n            disabled={isLoading}\n          >\n            保留远程\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/history-sheet.tsx",
    "content": "'use client'\n\nimport { History, ExternalLink, RotateCcw } from 'lucide-react'\nimport { useCallback, useEffect, useState } from 'react'\nimport { cn } from '@/lib/utils'\nimport useArticleStore from '@/stores/article'\nimport { Editor } from '@tiptap/react'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { getFileCommits as getGithubFileCommits, getFiles as getGithubFiles, decodeBase64ToString } from '@/lib/sync/github'\nimport { getFileCommits as getGiteeFileCommits, getFiles as getGiteeFiles, decodeBase64ToString as decodeGiteeBase64 } from '@/lib/sync/gitee'\nimport { getFileCommits as getGitlabFileCommits, getFileContent as getGitlabFileContent } from '@/lib/sync/gitlab'\nimport { getFileCommits as getGiteaFileCommits, getFileContentFromCommit as getGiteaFileContentFromCommit, getGiteaApiBaseUrl } from '@/lib/sync/gitea'\nimport { saveLocalFile } from '@/lib/sync/auto-sync'\nimport { updateFileSyncTime, updateFileRestoreTime } from '@/lib/sync/conflict-resolution'\nimport { toast } from '@/hooks/use-toast'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\n\ninterface CommitInfo {\n  sha: string\n  fullSha?: string // 完整 SHA，用于恢复功能\n  message: string\n  author: string\n  date: Date\n  url: string\n}\n\ntype SyncProvider = 'github' | 'gitee' | 'gitlab' | 'gitea'\n\ninterface HistorySheetProps {\n  editor: Editor\n}\n\nexport function HistorySheet({ editor }: HistorySheetProps) {\n  const { activeFilePath } = useArticleStore()\n  const [isOpen, setIsOpen] = useState(false)\n  const [history, setHistory] = useState<CommitInfo[]>([])\n  const [isLoading, setIsLoading] = useState(false)\n  const [restoringSha, setRestoringSha] = useState<string | null>(null)\n  const [provider, setProvider] = useState<SyncProvider | null>(null)\n  const [repoInfo, setRepoInfo] = useState<{ username?: string; projectId?: string; baseUrl?: string; repo?: string }>({})\n\n  // Get the sync provider\n  const getProvider = useCallback(async (): Promise<SyncProvider | null> => {\n    try {\n      const store = await Store.load('store.json')\n      const provider = await store.get<string>('primaryBackupMethod') || 'github'\n      return provider as SyncProvider\n    } catch {\n      return null\n    }\n  }, [])\n\n  // Load history\n  const loadHistory = useCallback(async () => {\n    if (!activeFilePath) return\n\n    setIsLoading(true)\n    try {\n      const provider = await getProvider()\n      if (!provider) return\n\n      const repo = await getSyncRepoName(provider)\n      let commits: any[] = []\n\n      switch (provider) {\n        case 'github': {\n          const result = await getGithubFileCommits({ path: activeFilePath, repo })\n          commits = (Array.isArray(result) ? result : []) as any[]\n          break\n        }\n        case 'gitee': {\n          const result = await getGiteeFileCommits({ path: activeFilePath, repo })\n          commits = (Array.isArray(result) ? result : []) as any[]\n          break\n        }\n        case 'gitlab': {\n          const result = await getGitlabFileCommits({ path: activeFilePath, repo })\n          // GitLab 返回 { data } 对象，需要从中提取数组\n          commits = (result && result.data) ? result.data : []\n          break\n        }\n        case 'gitea': {\n          const result = await getGiteaFileCommits({ path: activeFilePath, repo })\n          // Gitea 返回 { data } 对象，需要从中提取数组\n          commits = (result && result.data) ? result.data : []\n          break\n        }\n      }\n\n      const store = await Store.load('store.json')\n      let githubUsername: string | undefined\n      let giteeUsername: string | undefined\n      let gitlabProjectId: string | undefined\n      let giteaUsername: string | undefined\n      let giteaBaseUrl: string | undefined\n\n      switch (provider) {\n        case 'github':\n          githubUsername = await store.get('githubUsername')\n          break\n        case 'gitee':\n          giteeUsername = await store.get('giteeUsername')\n          break\n        case 'gitlab':\n          gitlabProjectId = await store.get<string>(`gitlab_${repo}_project_id`)\n          break\n        case 'gitea':\n          giteaUsername = await store.get('giteaUsername')\n          giteaBaseUrl = await getGiteaApiBaseUrl()\n          break\n      }\n\n      const getCommitUrl = (sha: string): string => {\n        switch (provider) {\n          case 'github':\n            return `https://github.com/${githubUsername}/${repo}/commit/${sha}`\n          case 'gitee':\n            return `https://gitee.com/${giteeUsername}/${repo}/commit/${sha}`\n          case 'gitlab':\n            return `https://gitlab.com/${gitlabProjectId?.split('/').pop()}/-/commit/${sha}`\n          case 'gitea':\n            return `${giteaBaseUrl?.replace('/api/v1', '')}/${giteaUsername}/${repo}/commit/${sha}`\n          default:\n            return ''\n        }\n      }\n\n      const historyData = commits.slice(0, 10).map((commit: any) => {\n        const sha = commit.sha || commit.id || ''\n        return {\n          sha: sha.slice(0, 7),\n          fullSha: sha, // 保存完整 SHA，用于恢复功能\n          message: commit.commit?.message || commit.message || 'No message',\n          author: commit.commit?.author?.name || commit.author?.name || commit.author_name || 'Unknown',\n          date: new Date(commit.commit?.author?.date || commit.created_at || commit.committed_date || Date.now()),\n          url: getCommitUrl(sha)\n        }\n      })\n\n      setHistory(historyData)\n      setProvider(provider)\n      setRepoInfo({\n        username: provider === 'github' ? githubUsername : provider === 'gitee' ? giteeUsername : provider === 'gitea' ? giteaUsername : undefined,\n        projectId: provider === 'gitlab' ? gitlabProjectId : undefined,\n        baseUrl: provider === 'gitea' ? giteaBaseUrl : undefined,\n        repo\n      })\n    } catch (error) {\n      console.error('Failed to load history:', error)\n      toast({\n        title: '加载失败',\n        description: '无法加载提交历史',\n        variant: 'destructive'\n      })\n    } finally {\n      setIsLoading(false)\n    }\n  }, [activeFilePath, getProvider])\n\n  // Restore file from specific commit\n  const restoreVersion = useCallback(async (commitSha: string) => {\n    if (!activeFilePath || restoringSha) return\n\n    setRestoringSha(commitSha)\n    try {\n      const provider = await getProvider()\n      if (!provider) return\n\n      const repo = await getSyncRepoName(provider)\n      let content = ''\n\n      switch (provider) {\n        case 'github': {\n          const fileInfo = await getGithubFiles({ path: activeFilePath, repo, ref: commitSha })\n          if (fileInfo?.content) {\n            content = decodeBase64ToString(fileInfo.content)\n          }\n          break\n        }\n        case 'gitee': {\n          const fileInfo = await getGiteeFiles({ path: activeFilePath, repo, ref: commitSha })\n          if (fileInfo?.content) {\n            // Gitee 也是 base64 编码\n            content = decodeGiteeBase64(fileInfo.content)\n          }\n          break\n        }\n        case 'gitlab': {\n          try {\n            const fileInfo = await getGitlabFileContent({ path: activeFilePath, ref: commitSha, repo })\n            if (fileInfo?.content) {\n              // GitLab 返回的是 base64 编码内容，需要解码\n              content = decodeBase64ToString(fileInfo.content)\n            }\n          } catch (e) {\n            console.error('[HistorySheet] GitLab 获取内容失败:', e)\n          }\n          break\n        }\n        case 'gitea': {\n          try {\n            // 使用 getFileContentFromCommit 通过 Git tree API 获取特定 commit 的文件内容\n            const fileInfo = await getGiteaFileContentFromCommit({ path: activeFilePath, ref: commitSha, repo })\n            if (fileInfo && fileInfo.content) {\n              // Gitea 返回的是 base64 编码内容，需要解码\n              content = decodeGiteeBase64(fileInfo.content)\n            }\n          } catch (e) {\n            console.error('[HistorySheet] Gitea 获取内容失败:', e)\n          }\n          break\n        }\n      }\n\n\n      if (content) {\n        // 保存到本地文件\n        await saveLocalFile(activeFilePath, content)\n\n        // 更新编辑器内容\n        editor.commands.clearContent()\n        editor.commands.setContent(content, { contentType: 'markdown' })\n\n        // 更新同步时间和恢复时间\n        await updateFileSyncTime(activeFilePath)\n        await updateFileRestoreTime(activeFilePath)\n\n        toast({\n          title: '已恢复',\n          description: '已从历史版本恢复文件'\n        })\n\n        setIsOpen(false)\n      }\n    } catch (error) {\n      console.error('Failed to restore version:', error)\n      toast({\n        title: '恢复失败',\n        description: '无法从历史版本恢复文件',\n        variant: 'destructive'\n      })\n    } finally {\n      setRestoringSha(null)\n    }\n  }, [activeFilePath, editor, getProvider, restoringSha])\n\n  // Load history when sheet opens\n  useEffect(() => {\n    if (isOpen && activeFilePath) {\n      loadHistory()\n    }\n  }, [isOpen, activeFilePath, loadHistory])\n\n  if (!activeFilePath) return null\n\n  return (\n    <Popover open={isOpen} onOpenChange={setIsOpen}>\n      <PopoverTrigger asChild>\n        <button\n          className={cn(\n            'p-0.5 rounded transition-colors hover:bg-[hsl(var(--muted))]',\n            isOpen && 'bg-[hsl(var(--muted))]'\n          )}\n          title=\"历史记录\"\n        >\n          <History size={14} />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" side=\"top\" className=\"w-90 max-h-100 overflow-hidden flex flex-col\">\n        <div className=\"flex items-center justify-between mb-2\">\n          <div className=\"font-semibold text-sm\">提交历史</div>\n          {activeFilePath && provider && repoInfo.repo && (\n            <a\n              href={(() => {\n                switch (provider) {\n                  case 'github': return `https://github.com/${repoInfo.username}/${repoInfo.repo}/blob/main/${activeFilePath}`\n                  case 'gitee': return `https://gitee.com/${repoInfo.username}/${repoInfo.repo}/blob/master/${activeFilePath}`\n                  case 'gitlab': return `https://gitlab.com/${repoInfo.projectId?.split('/').pop()}/-/blob/main/${activeFilePath}`\n                  case 'gitea': return `${repoInfo.baseUrl?.replace('/api/v1', '')}/${repoInfo.username}/${repoInfo.repo}/src/branch/main/${activeFilePath}`\n                  default: return '#'\n                }\n              })()}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-xs text-muted-foreground hover:text-primary inline-flex items-center gap-1\"\n              title=\"在仓库中打开\"\n            >\n              <ExternalLink size={10} />\n              <span className=\"truncate max-w-30\">{activeFilePath.split('/').pop()}</span>\n            </a>\n          )}\n        </div>\n        <div className=\"flex-1 overflow-y-auto pr-1\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-8 text-muted-foreground\">\n              加载中...\n            </div>\n          ) : history.length === 0 ? (\n            <div className=\"py-8 text-center text-muted-foreground\">\n              暂无提交记录\n            </div>\n          ) : (\n            <ul className=\"space-y-2\">\n              {history.map((commit, index) => (\n                <li\n                  key={commit.sha + index}\n                  className=\"p-2 border rounded hover:bg-muted/50 transition-colors\"\n                >\n                  <div className=\"flex items-center justify-between mb-1\">\n                    <a\n                      href={commit.url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-xs font-mono text-muted-foreground hover:text-primary inline-flex items-center gap-1\"\n                    >\n                      {commit.sha}\n                      <ExternalLink size={10} />\n                    </a>\n                    <span className=\"text-xs text-muted-foreground\">\n                      {commit.date.toLocaleString()}\n                    </span>\n                  </div>\n                  <p className=\"text-sm truncate\" title={commit.message}>\n                    {commit.message}\n                  </p>\n                  <div className=\"flex items-center justify-between mt-2\">\n                    <p className=\"text-xs text-muted-foreground\">\n                      {commit.author}\n                    </p>\n                    <button\n                      onClick={() => restoreVersion(commit.fullSha || commit.sha)}\n                      disabled={restoringSha === commit.sha}\n                      className={cn(\n                        'text-xs text-blue-500 hover:text-blue-600 inline-flex items-center gap-1',\n                        restoringSha === commit.sha && 'opacity-50'\n                      )}\n                      title=\"恢复此版本\"\n                    >\n                      <RotateCcw size={12} />\n                      {restoringSha === commit.sha ? '恢复中...' : '恢复'}\n                    </button>\n                  </div>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport default HistorySheet\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/index.ts",
    "content": "export { SyncTools } from './sync-tools'\nexport { SyncButton } from './sync-button'\nexport { PullButton } from './pull-button'\nexport { HistorySheet } from './history-sheet'\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/pull-button.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport { ArrowDownCircle, Loader2 } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport useArticleStore from '@/stores/article'\nimport useSettingStore from '@/stores/setting'\nimport { compareFileVersions, pullRemoteFile, saveLocalFile, getRemoteFileInfo, setLocalRecordedSha } from '@/lib/sync/auto-sync'\nimport { updateFileSyncTime } from '@/lib/sync/conflict-resolution'\nimport { isSyncConfigured } from '@/lib/sync/sync-manager'\nimport emitter from '@/lib/emitter'\nimport { toast } from '@/hooks/use-toast'\nimport { ConflictDialog } from './conflict-dialog'\n\ninterface PullButtonProps {\n  editor: Editor\n}\n\n// 拉取状态\ntype PullStatus = 'idle' | 'checking' | 'update-available' | 'pulling' | 'conflict' | 'error'\n\nexport function PullButton({ editor }: PullButtonProps) {\n  const { activeFilePath } = useArticleStore()\n  const { autoPullOnSwitch } = useSettingStore()\n  const [hasUpdate, setHasUpdate] = useState(false)\n  const [isLoading, setIsLoading] = useState(false)\n  const [isConfigured, setIsConfigured] = useState(false)\n  const [pullStatus, setPullStatus] = useState<PullStatus>('idle')\n  const [showConflictDialog, setShowConflictDialog] = useState(false)\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n  const lastInputTimeRef = useRef<number>(Date.now())\n\n  // 编辑器状态检测\n  const [isEditorFocused, setIsEditorFocused] = useState(false)\n  const [hasSelection, setHasSelection] = useState(false)\n\n  // 用于防抖和竞态处理\n  const pendingFileRef = useRef<string | null>(null)\n  const pullTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  // 远程内容缓存（用于显示更新提示）\n  const remoteContentRef = useRef<string | null>(null)\n\n  const IDLE_PULL_INTERVAL = 30 * 1000 // 30 秒\n  const IDLE_THRESHOLD = 10 * 1000 // 用户停止输入 10 秒后开始计时\n\n  // 检测编辑器状态\n  useEffect(() => {\n    if (!editor) return\n\n    const handleFocus = () => setIsEditorFocused(true)\n    const handleBlur = () => setIsEditorFocused(false)\n    const handleSelectionUpdate = () => {\n      const selection = editor.state.selection\n      const from = selection.from\n      const to = selection.to\n      setHasSelection(from !== to)\n    }\n\n    editor.on('focus', handleFocus)\n    editor.on('blur', handleBlur)\n    editor.on('selectionUpdate', handleSelectionUpdate)\n\n    // 初始化状态\n    setIsEditorFocused(editor.isFocused)\n    const selection = editor.state.selection\n    setHasSelection(selection.from !== selection.to)\n\n    return () => {\n      editor.off('focus', handleFocus)\n      editor.off('blur', handleBlur)\n      editor.off('selectionUpdate', handleSelectionUpdate)\n    }\n  }, [editor])\n\n  // Check if sync is configured\n  useEffect(() => {\n    isSyncConfigured().then(setIsConfigured)\n  }, [])\n\n  // 检查用户是否正在活跃编辑\n  const isUserActive = isEditorFocused || hasSelection\n  const timeSinceInput = Date.now() - lastInputTimeRef.current\n\n  // 执行实际的拉取操作\n  const executePull = useCallback(async (remoteContent: string) => {\n    if (!activeFilePath) return\n\n    setIsLoading(true)\n    try {\n      await saveLocalFile(activeFilePath, remoteContent)\n      // 使用 contentType: 'markdown' 让 @tiptap/markdown 扩展解析 Markdown\n      editor.commands.setContent(remoteContent, { contentType: 'markdown' })\n      // 更新同步时间，避免重复检测\n      await updateFileSyncTime(activeFilePath)\n      // 更新本地记录的远程 SHA，避免重复提示有更新\n      const remoteInfo = await getRemoteFileInfo(activeFilePath)\n      if (remoteInfo.sha) {\n        await setLocalRecordedSha(activeFilePath, remoteInfo.sha)\n      }\n      // 触发事件，让推送队列重置计时器\n      emitter.emit('sync-pulled', { path: activeFilePath })\n      // 清除远程内容缓存\n      remoteContentRef.current = null\n      setPullStatus('idle')\n      setHasUpdate(false)\n    } catch (error) {\n      console.error('Pull failed:', error)\n      setPullStatus('error')\n      toast({\n        title: '拉取失败',\n        description: error instanceof Error ? error.message : '请检查网络连接后重试',\n        variant: 'destructive',\n      })\n    } finally {\n      setIsLoading(false)\n    }\n  }, [activeFilePath, editor])\n\n  // Auto pull from remote (called by interval)\n  const checkForUpdates = useCallback(async () => {\n    if (!activeFilePath || isLoading) {\n      return\n    }\n\n    // 如果用户正在编辑（停止输入不到 10 秒），延迟拉取\n    if (timeSinceInput < IDLE_THRESHOLD) {\n      setPullStatus('idle')\n      return\n    }\n\n    try {\n      setPullStatus('checking')\n      const result = await compareFileVersions(activeFilePath)\n\n      if (result.action === 'conflict') {\n        setPullStatus('conflict')\n        return\n      }\n\n      if (result.action === 'pull') {\n        // 缓存远程内容\n        try {\n          const content = await pullRemoteFile(activeFilePath)\n          remoteContentRef.current = content\n          // 检测到更新，但不自动拉取，只显示状态\n          setPullStatus('update-available')\n          setHasUpdate(true)\n          return\n        } catch {\n          // 如果拉取失败，标记为错误\n          setPullStatus('error')\n          toast({\n            title: '获取远程更新失败',\n            description: '请检查网络连接后重试',\n            variant: 'destructive',\n          })\n          return\n        }\n      }\n\n      // 没有更新\n      setPullStatus('idle')\n      setHasUpdate(false)\n      remoteContentRef.current = null\n    } catch (error) {\n      console.error('Auto pull check failed:', error)\n      setPullStatus('error')\n      // 静默处理自动检查的错误，不弹 toast 打扰用户\n    }\n  }, [activeFilePath, isLoading, isUserActive, timeSinceInput])\n\n  // 处理冲突 - 打开对比对话框\n  const handleConflict = useCallback(() => {\n    setShowConflictDialog(true)\n  }, [])\n\n  // 冲突解决后的回调\n  const handleConflictResolved = useCallback(() => {\n    setPullStatus('idle')\n    setHasUpdate(false)\n    remoteContentRef.current = null\n  }, [])\n\n  // Check for updates and auto pull when file changes\n  useEffect(() => {\n    if (!activeFilePath || !isConfigured) return\n\n    // 清理之前的定时器\n    if (pullTimeoutRef.current) {\n      clearTimeout(pullTimeoutRef.current)\n      pullTimeoutRef.current = null\n    }\n\n    // 文件切换时，重置最后输入时间，让首次检测可以立即执行\n    lastInputTimeRef.current = 0\n\n    // 文件切换时也使用新的检测逻辑，不自动拉取\n    const checkOnSwitch = async () => {\n      // 竞态检查：如果当前正在处理的文件不是这个了，忽略\n      if (pendingFileRef.current !== null && pendingFileRef.current !== activeFilePath) {\n        return\n      }\n\n      pendingFileRef.current = activeFilePath\n\n      // 清除之前的缓存\n      remoteContentRef.current = null\n\n      try {\n        // 文件切换时总是检测（用户主动打开的文件，检测更新不会打扰用户）\n        // 只有定时器检测才需要考虑用户是否在编辑\n        const result = await compareFileVersions(activeFilePath)\n\n        // 再次检查是否还是当前文件（可能已经切换走了）\n        if (pendingFileRef.current !== activeFilePath) {\n          return\n        }\n\n        if (result.action === 'conflict') {\n          // 有冲突时，根据 autoPullOnSwitch 配置决定是否自动拉取\n          // 先显示检查中状态\n          setPullStatus('checking')\n          setIsLoading(true)\n\n          if (autoPullOnSwitch) {\n            // 禁用编辑器\n            editor.setEditable(false)\n            const content = await pullRemoteFile(activeFilePath)\n\n            if (pendingFileRef.current !== activeFilePath) {\n              setIsLoading(false)\n              editor.setEditable(true)\n              return\n            }\n\n            await saveLocalFile(activeFilePath, content)\n            editor.commands.setContent(content, { contentType: 'markdown' })\n            // 恢复编辑器\n            editor.setEditable(true)\n            setIsLoading(false)\n          } else {\n            // 不自动拉取，只显示冲突状态\n            setPullStatus('conflict')\n            setIsLoading(false)\n          }\n        } else if (result.action === 'pull') {\n          // 切换文件时检测到更新，根据 autoPullOnSwitch 配置决定是否自动拉取\n          // 先显示检查中状态\n          setPullStatus('checking')\n          setIsLoading(true)\n\n          if (autoPullOnSwitch) {\n            // 禁用编辑器\n            editor.setEditable(false)\n            const content = await pullRemoteFile(activeFilePath)\n\n            // 拉取后再次检查是否还是当前文件\n            if (pendingFileRef.current !== activeFilePath) {\n              setIsLoading(false)\n              editor.setEditable(true)\n              return\n            }\n\n            await saveLocalFile(activeFilePath, content)\n\n            editor.commands.setContent(content, { contentType: 'markdown' })\n            await updateFileSyncTime(activeFilePath)\n            emitter.emit('sync-pulled', { path: activeFilePath })\n            // 恢复编辑器\n            editor.setEditable(true)\n            setIsLoading(false)\n            setHasUpdate(false)\n          } else {\n            // 不自动拉取，只提示有更新，但先显示 loading 状态\n            try {\n              const content = await pullRemoteFile(activeFilePath)\n              remoteContentRef.current = content\n              setPullStatus('update-available')\n              setHasUpdate(true)\n              setIsLoading(false)\n            } catch {\n              setPullStatus('error')\n              setIsLoading(false)\n            }\n          }\n        } else {\n          setPullStatus('idle')\n          setHasUpdate(false)\n        }\n      } catch {\n        setHasUpdate(false)\n      } finally {\n        // 只有当这是最后一个请求时才清除标记\n        if (pendingFileRef.current === activeFilePath) {\n          pendingFileRef.current = null\n        }\n      }\n    }\n\n    // 防抖：延迟 500ms 执行，等待用户停止切换\n    pullTimeoutRef.current = setTimeout(checkOnSwitch, 500)\n\n    return () => {\n      if (pullTimeoutRef.current) {\n        clearTimeout(pullTimeoutRef.current)\n        pullTimeoutRef.current = null\n      }\n    }\n  }, [activeFilePath, isConfigured, editor, isUserActive, autoPullOnSwitch])\n\n  // 监听用户输入事件，重置计时器\n  useEffect(() => {\n    const handleInput = () => {\n      lastInputTimeRef.current = Date.now()\n    }\n    emitter.on('editor-input', handleInput)\n    return () => {\n      emitter.off('editor-input', handleInput)\n    }\n  }, [])\n\n  // Set up auto-pull interval (now only checks, doesn't auto-pull)\n  useEffect(() => {\n    if (!isConfigured || !activeFilePath) return\n\n    const checkForUpdatesPeriodically = () => {\n      // 使用 ref 中的最新值\n      const now = Date.now()\n      const timeSinceInput = now - lastInputTimeRef.current\n      // 用户停止输入超过阈值时才检查\n      if (timeSinceInput >= IDLE_THRESHOLD) {\n        checkForUpdates()\n      }\n    }\n\n    intervalRef.current = setInterval(checkForUpdatesPeriodically, IDLE_PULL_INTERVAL)\n\n    return () => {\n      if (intervalRef.current) {\n        clearInterval(intervalRef.current)\n        intervalRef.current = null\n      }\n    }\n  }, [isConfigured, activeFilePath, checkForUpdates])\n\n  // Pull from remote (manual) - 使用缓存的远程内容\n  const handlePull = useCallback(async () => {\n    if (!activeFilePath || isLoading) return\n\n    // 如果有缓存的远程内容，直接使用\n    if (remoteContentRef.current) {\n      await executePull(remoteContentRef.current)\n      return\n    }\n\n    // 如果没有缓存，重新拉取\n    setIsLoading(true)\n    try {\n      const content = await pullRemoteFile(activeFilePath)\n      await executePull(content)\n    } catch (error) {\n      console.error('Pull failed:', error)\n    }\n  }, [activeFilePath, isLoading, executePull])\n\n  // 如果没有配置同步，不显示\n  if (!isConfigured || !activeFilePath) return null\n\n  return (\n    <>\n      <div className=\"flex items-center gap-1\">\n        {/* 拉取中状态 */}\n        {isLoading ? (\n          <span className=\"text-xs text-muted-foreground flex items-center gap-1\">\n            <Loader2 size={12} className=\"animate-spin\" />\n            拉取中...\n          </span>\n        ) : pullStatus === 'conflict' ? (\n          /* 冲突状态 - 提示用户处理 */\n          <button\n            onClick={handleConflict}\n            className=\"p-0.5 rounded transition-colors hover:bg-red-500/10 text-red-500 flex items-center gap-1\"\n            title=\"处理冲突\"\n          >\n            <ArrowDownCircle size={14} />\n            <span className=\"text-xs\">有冲突</span>\n          </button>\n        ) : hasUpdate ? (\n          /* 有更新可以拉取 */\n          <button\n            onClick={handlePull}\n            className=\"p-0.5 rounded transition-colors hover:bg-amber-500/10 text-amber-500 flex items-center gap-1\"\n            title=\"拉取更新\"\n          >\n            <ArrowDownCircle size={14} />\n            <span className=\"text-xs\">有更新</span>\n          </button>\n        ) : pullStatus === 'checking' ? (\n          /* 检查中状态 */\n          <span className=\"text-xs text-muted-foreground flex items-center gap-1\">\n            <Loader2 size={12} className=\"animate-spin\" />\n            检查中\n          </span>\n        ) : (\n          /* 无需拉取时也显示可点击的按钮，让用户可以手动拉取 */\n          <button\n            onClick={handlePull}\n            className=\"p-0.5 rounded transition-colors hover:bg-accent text-muted-foreground flex items-center gap-1\"\n            title=\"手动拉取远程文件\"\n          >\n            <ArrowDownCircle size={14} />\n          </button>\n        )}\n      </div>\n\n      {/* 冲突对比对话框 */}\n      <ConflictDialog\n        open={showConflictDialog}\n        onOpenChange={setShowConflictDialog}\n        activeFilePath={activeFilePath}\n        editor={editor}\n        onResolved={handleConflictResolved}\n      />\n    </>\n  )\n}\n\nexport default PullButton\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/sync-button.tsx",
    "content": "'use client'\n\nimport { ArrowUpCircle, CheckCircle, Loader2, XCircle } from 'lucide-react'\nimport { useCallback, useEffect, useState, useRef } from 'react'\nimport { cn } from '@/lib/utils'\nimport useArticleStore from '@/stores/article'\nimport useSyncStore from '@/stores/sync'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { getWorkspacePath, getFilePathOptions } from '@/lib/workspace'\nimport { readTextFile } from '@tauri-apps/plugin-fs'\nimport { isSyncConfigured } from '@/lib/sync/sync-manager'\nimport emitter from '@/lib/emitter'\n\nexport function SyncButton() {\n  const { activeFilePath } = useArticleStore()\n  const [isLoading, setIsLoading] = useState(false)\n  const [isConfigured, setIsConfigured] = useState(false)\n  const [showSuccess, setShowSuccess] = useState(false)\n  const [showError, setShowError] = useState(false)\n  const [lastPushTime, setLastPushTime] = useState<Date | null>(null)\n  const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  // Check if sync is configured\n  useEffect(() => {\n    isSyncConfigured().then(setIsConfigured)\n  }, [])\n\n  // 监听推送开始事件\n  useEffect(() => {\n    const handlePushStarted = (event: { path: string }) => {\n      if (activeFilePath && event.path === activeFilePath) {\n        setIsLoading(true)\n      }\n    }\n    emitter.on('sync-push-started', handlePushStarted as any)\n    return () => {\n      emitter.off('sync-push-started', handlePushStarted as any)\n    }\n  }, [activeFilePath])\n\n  // 监听推送完成事件\n  useEffect(() => {\n    const handlePushCompleted = (event: { path: string; success: boolean }) => {\n      if (activeFilePath && event.path === activeFilePath) {\n        setIsLoading(false)\n        if (event.success) {\n          // 显示成功状态\n          setShowError(false)\n          setShowSuccess(true)\n          setLastPushTime(new Date())\n          // 5秒后恢复\n          if (successTimerRef.current) {\n            clearTimeout(successTimerRef.current)\n          }\n          successTimerRef.current = setTimeout(() => {\n            setShowSuccess(false)\n          }, 5000)\n        } else {\n          // 显示失败状态\n          setShowSuccess(false)\n          setShowError(true)\n          // 5秒后恢复\n          if (errorTimerRef.current) {\n            clearTimeout(errorTimerRef.current)\n          }\n          errorTimerRef.current = setTimeout(() => {\n            setShowError(false)\n          }, 5000)\n        }\n      }\n    }\n    emitter.on('sync-push-completed', handlePushCompleted as any)\n    return () => {\n      emitter.off('sync-push-completed', handlePushCompleted as any)\n      if (successTimerRef.current) {\n        clearTimeout(successTimerRef.current)\n      }\n      if (errorTimerRef.current) {\n        clearTimeout(errorTimerRef.current)\n      }\n    }\n  }, [activeFilePath])\n\n  // Generate AI commit message\n  const generateCommitMessage = useCallback(async (content: string): Promise<string> => {\n    try {\n      const { fetchAi } = await import('@/lib/ai/chat')\n      const prompt = `请为以下文档内容生成一个简洁的 Git 提交信息（不超过 50 个字符）：\n\n${content.slice(0, 1000)}${content.length > 1000 ? '...' : ''}\n\n直接返回提交信息，不需要任何解释或格式。`\n      const message = await fetchAi(prompt, 'commitModel')\n      return message.trim().slice(0, 50) || `Update ${activeFilePath}`\n    } catch {\n      return `Update ${activeFilePath}`\n    }\n  }, [activeFilePath])\n\n  // Push to remote\n  const handlePush = useCallback(async () => {\n    if (!activeFilePath || isLoading) return\n\n    setIsLoading(true)\n    try {\n      const store = await Store.load('store.json')\n      const provider = (await store.get<string>('primaryBackupMethod') || 'github') as 'gitee' | 'github' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n      // S3 和 WebDAV 不需要 repo\n      const repo = (provider === 's3' || provider === 'webdav') ? '' : await getSyncRepoName(provider)\n\n      // 始终从磁盘读取最新内容\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(activeFilePath)\n      const content = workspace.isCustom\n        ? await readTextFile(pathOptions.path)\n        : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n\n      const commitMessage = await generateCommitMessage(content)\n\n      let success = false\n\n      switch (provider) {\n        case 's3': {\n          const s3Module = await import('@/lib/sync/s3') as any\n          const s3Config = await store.get<any>('s3SyncConfig')\n          if (!s3Config) {\n            throw new Error('S3 配置未找到')\n          }\n          // S3 上传文件\n          const result = await s3Module.s3Upload(s3Config, activeFilePath, content)\n          if (result) {\n            // 更新 ETag 记录\n            useSyncStore.getState().updateS3FileEtag(activeFilePath, result.etag)\n            success = true\n          }\n          break\n        }\n        case 'github': {\n          const githubModule = await import('@/lib/sync/github') as any\n          const fileInfo = await githubModule.getFiles({ path: activeFilePath, repo })\n          await githubModule.uploadFile({\n            ext: activeFilePath.split('.').pop() || 'md',\n            file: content,\n            filename: activeFilePath.split('/').pop() || activeFilePath,\n            sha: fileInfo?.sha,\n            message: commitMessage,\n            repo,\n            path: activeFilePath\n          })\n          success = true\n          break\n        }\n        case 'gitee': {\n          const giteeModule = await import('@/lib/sync/gitee') as any\n          const fileInfo = await giteeModule.getFiles({ path: activeFilePath, repo })\n          await giteeModule.uploadFile({\n            ext: activeFilePath.split('.').pop() || 'md',\n            file: content,\n            filename: activeFilePath.split('/').pop() || activeFilePath,\n            sha: fileInfo?.sha,\n            message: commitMessage,\n            repo,\n            path: activeFilePath\n          })\n          success = true\n          break\n        }\n        case 'gitlab': {\n          const gitlabModule = await import('@/lib/sync/gitlab') as any\n          const fileInfo = await gitlabModule.getFiles({ path: activeFilePath, repo })\n          await gitlabModule.uploadFile({\n            file: content,\n            filename: activeFilePath.split('/').pop() || activeFilePath,\n            sha: fileInfo?.sha,\n            message: commitMessage,\n            repo,\n            path: activeFilePath\n          })\n          success = true\n          break\n        }\n        case 'gitea': {\n          const giteaModule = await import('@/lib/sync/gitea') as any\n          const fileInfo = await giteaModule.getFiles({ path: activeFilePath, repo })\n          await giteaModule.uploadFile({\n            file: content,\n            filename: activeFilePath.split('/').pop() || activeFilePath,\n            sha: fileInfo?.sha,\n            message: commitMessage,\n            repo,\n            path: activeFilePath\n          })\n          success = true\n          break\n        }\n        case 'webdav': {\n          const webdavModule = await import('@/lib/sync/webdav') as any\n          const webdavConfig = await store.get<any>('webdavSyncConfig')\n          if (!webdavConfig) {\n            throw new Error('WebDAV 配置未找到')\n          }\n          const result = await webdavModule.webdavUpload(webdavConfig, activeFilePath, content)\n          if (result) {\n            // 更新 ETag 记录\n            useSyncStore.getState().updateWebDAVFileEtag(activeFilePath, result.etag)\n            success = true\n          }\n          break\n        }\n      }\n\n      if (success) {\n        emitter.emit('sync-push-completed', { path: activeFilePath, success: true })\n      } else {\n        throw new Error('File may not exist on remote')\n      }\n    } catch (error) {\n      console.error('Push failed:', error)\n      setIsLoading(false)\n      emitter.emit('sync-push-completed', { path: activeFilePath, success: false })\n    }\n  }, [activeFilePath, isLoading, generateCommitMessage])\n\n  // 如果没有配置同步，不显示按钮\n  if (!isConfigured || !activeFilePath) return null\n\n  // 格式化时间\n  const formatTime = (date: Date) => {\n    return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n  }\n\n  return (\n    <div className=\"flex items-center gap-1.5\">\n      {/* 上传中显示文字 */}\n      {isLoading && (\n        <span className=\"text-xs text-muted-foreground flex items-center gap-1\">\n          <Loader2 size={12} className=\"animate-spin\" />\n          上传中\n        </span>\n      )}\n\n      {/* 成功推送状态 */}\n      {showSuccess && !isLoading && (\n        <span className=\"text-xs text-green-500 flex items-center gap-1 animate-pulse\">\n          <CheckCircle size={12} />\n          {lastPushTime && formatTime(lastPushTime)}\n        </span>\n      )}\n\n      {/* 失败推送状态 */}\n      {showError && !isLoading && (\n        <span className=\"text-xs text-red-500 flex items-center gap-1\">\n          <XCircle size={12} />\n          上传失败\n        </span>\n      )}\n\n      {/* 同步按钮 */}\n      {!showSuccess && !showError && !isLoading && (\n        <button\n          onClick={handlePush}\n          disabled={isLoading}\n          className={cn(\n            'p-0.5 rounded transition-colors flex items-center gap-1 text-muted-foreground hover:text-foreground hover:bg-muted'\n          )}\n          title={isLoading ? '上传中...' : '点击推送'}\n        >\n          <ArrowUpCircle size={14} />\n        </button>\n      )}\n    </div>\n  )\n}\n\nexport default SyncButton\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/sync/sync-tools.tsx",
    "content": "'use client'\nimport { Editor } from '@tiptap/react'\nimport { useTranslations } from 'next-intl'\nimport { SyncButton } from './sync-button'\nimport { PullButton } from './pull-button'\nimport { HistorySheet } from './history-sheet'\nimport { useRouter } from 'next/navigation'\nimport { isSyncConfigured } from '@/lib/sync/sync-manager'\nimport { useEffect, useState } from 'react'\n\ninterface SyncToolsProps {\n  editor: Editor\n}\n\nexport function SyncTools({ editor }: SyncToolsProps) {\n  const t = useTranslations('common')\n  const router = useRouter()\n  const [configured, setConfigured] = useState(false)\n\n  useEffect(() => {\n    isSyncConfigured().then(setConfigured)\n  }, [])\n\n  const handleConfigureSync = () => {\n    router.push('/core/setting/sync')\n  }\n\n  if (configured) {\n    return (\n      <div className=\"flex items-center gap-1\">\n        <HistorySheet editor={editor} />\n        <SyncButton />\n        <PullButton editor={editor} />\n      </div>\n    )\n  }\n\n  return (\n    <button\n      onClick={handleConfigureSync}\n      className=\"flex items-center gap-0.5 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors\"\n      title={t('configureSync')}\n    >\n      <span>{t('configureSync')}</span>\n    </button>\n  )\n}\n\nexport default SyncTools\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/table-toolbar.tsx",
    "content": "'use client'\n\nimport { Editor } from '@tiptap/react'\nimport {\n  Table as TableIcon,\n  Columns,\n  Rows,\n  Trash2,\n  AlignLeft,\n  AlignCenter,\n  AlignRight,\n} from 'lucide-react'\nimport { useCallback } from 'react'\n\ninterface TableToolbarProps {\n  editor: Editor\n}\n\nexport function TableToolbar({ editor }: TableToolbarProps) {\n  const canInsertTable = editor.can().insertTable({ rows: 3, cols: 3, withHeaderRow: true })\n\n  const insertTable = useCallback(() => {\n    editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()\n  }, [editor])\n\n  const addColumnBefore = useCallback(() => {\n    editor.chain().focus().addColumnBefore().run()\n  }, [editor])\n\n  const addColumnAfter = useCallback(() => {\n    editor.chain().focus().addColumnAfter().run()\n  }, [editor])\n\n  const addRowBefore = useCallback(() => {\n    editor.chain().focus().addRowBefore().run()\n  }, [editor])\n\n  const addRowAfter = useCallback(() => {\n    editor.chain().focus().addRowAfter().run()\n  }, [editor])\n\n  const deleteColumn = useCallback(() => {\n    editor.chain().focus().deleteColumn().run()\n  }, [editor])\n\n  const deleteRow = useCallback(() => {\n    editor.chain().focus().deleteRow().run()\n  }, [editor])\n\n  const deleteTable = useCallback(() => {\n    editor.chain().focus().deleteTable().run()\n  }, [editor])\n\n  const setColumnAlignmentLeft = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'left').run()\n  }, [editor])\n\n  const setColumnAlignmentCenter = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'center').run()\n  }, [editor])\n\n  const setColumnAlignmentRight = useCallback(() => {\n    editor.chain().focus().setCellAttribute('align', 'right').run()\n  }, [editor])\n\n  const isTableActive = editor.isActive('table')\n\n  return (\n    <div className=\"table-toolbar relative\">\n      <button\n        onClick={insertTable}\n        disabled={!canInsertTable}\n        className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n        title=\"插入表格\"\n      >\n        <TableIcon size={18} />\n      </button>\n\n      {isTableActive && (\n        <div className=\"flex items-center gap-1 ml-2 border-l border-gray-300 dark:border-gray-600 pl-2\">\n          <button\n            onClick={addColumnBefore}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"在左侧插入列\"\n          >\n            <Columns size={18} className=\"rotate-180\" />\n          </button>\n          <button\n            onClick={addColumnAfter}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"在右侧插入列\"\n          >\n            <Columns size={18} />\n          </button>\n          <button\n            onClick={addRowBefore}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"在上方插入行\"\n          >\n            <Rows size={18} className=\"rotate-180\" />\n          </button>\n          <button\n            onClick={addRowAfter}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"在下方插入行\"\n          >\n            <Rows size={18} />\n          </button>\n          <div className=\"w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1\" />\n          <button\n            onClick={setColumnAlignmentLeft}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"左对齐\"\n          >\n            <AlignLeft size={18} />\n          </button>\n          <button\n            onClick={setColumnAlignmentCenter}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"居中对齐\"\n          >\n            <AlignCenter size={18} />\n          </button>\n          <button\n            onClick={setColumnAlignmentRight}\n            className=\"p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700\"\n            title=\"右对齐\"\n          >\n            <AlignRight size={18} />\n          </button>\n          <div className=\"w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1\" />\n          <button\n            onClick={deleteColumn}\n            className=\"p-2 rounded hover:bg-red-200 dark:hover:bg-red-900 text-red-600\"\n            title=\"删除列\"\n          >\n            <Trash2 size={18} />\n          </button>\n          <button\n            onClick={deleteRow}\n            className=\"p-2 rounded hover:bg-red-200 dark:hover:bg-red-900 text-red-600\"\n            title=\"删除行\"\n          >\n            <Rows size={18} className=\"rotate-45\" />\n          </button>\n          <button\n            onClick={deleteTable}\n            className=\"p-2 rounded hover:bg-red-200 dark:hover:bg-red-900 text-red-600\"\n            title=\"删除表格\"\n          >\n            <Trash2 size={18} />\n          </button>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/editor/markdown/tiptap-editor.tsx",
    "content": "'use client'\n\nimport { useEditor, EditorContent } from '@tiptap/react'\nimport StarterKit from '@tiptap/starter-kit'\nimport Placeholder from '@tiptap/extension-placeholder'\nimport Link from '@tiptap/extension-link'\nimport TaskList from '@tiptap/extension-task-list'\nimport TaskItem from '@tiptap/extension-task-item'\nimport CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'\nimport CharacterCount from '@tiptap/extension-character-count'\nimport Highlight from '@tiptap/extension-highlight'\nimport Underline from '@tiptap/extension-underline'\nimport TextAlign from '@tiptap/extension-text-align'\nimport Typography from '@tiptap/extension-typography'\nimport Dropcursor from '@tiptap/extension-dropcursor'\nimport { Table } from '@tiptap/extension-table'\nimport { TableRow } from '@tiptap/extension-table-row'\nimport { TableCell } from '@tiptap/extension-table-cell'\nimport { TableHeader } from '@tiptap/extension-table-header'\nimport Image from '@tiptap/extension-image'\nimport { common, createLowlight } from 'lowlight'\nimport { Markdown } from '@tiptap/markdown'\nimport { SearchAndReplace } from '@sereneinserenade/tiptap-search-and-replace'\nimport UniqueId from '@tiptap/extension-unique-id'\nimport { Extension, nodeInputRule } from '@tiptap/core'\nimport { Plugin, TextSelection } from '@tiptap/pm/state'\nimport { Node as ProseMirrorNode } from '@tiptap/pm/model'\nimport 'katex/dist/katex.min.css'\nimport { InlineMath, BlockMath } from './math-extension'\nimport { MermaidDiagram } from './mermaid-extension'\nimport { MathEditorDialog } from './math-editor-dialog'\nimport { SearchReplacePanel } from './search-replace-panel'\nimport { useEffect, useRef, useCallback, useState } from 'react'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { openUrl } from '@tauri-apps/plugin-opener'\nimport { handleImageUpload } from '@/lib/image-handler'\nimport useArticleStore from '@/stores/article'\nimport { convertImageByWorkspace } from '@/lib/utils'\nimport { isMobileDevice } from '@/lib/check'\nimport { useTranslations } from 'next-intl'\nimport { BubbleMenu as BubbleMenuComponent } from './bubble-menu'\nimport { ImageBubbleMenu } from './image-bubble-menu'\nimport { toast } from '@/hooks/use-toast'\nimport { FloatingTableMenu } from './floating-table-menu'\nimport { FooterBar } from './footer-bar/index'\nimport { SlashCommand, suggestionOptions } from './slash-command'\nimport { SlashCommandPortal } from './slash-command/slash-command-portal'\nimport { fetchCompletionStream } from '@/lib/ai/completion'\nimport { fetchAiPolishStream, fetchAiConciseStream, fetchAiExpandStream } from '@/lib/ai/rewrite'\nimport { AISuggestion } from './ai-suggestion'\nimport { AISuggestionFloating } from './ai-suggestion-floating'\nimport emitter from '@/lib/emitter'\nimport { QuoteMark } from './quote-mark'\nimport { MarkdownParagraph } from './markdown-paragraph'\nimport useSettingStore from '@/stores/setting'\nimport useChatStore from '@/stores/chat'\nimport { Loader2, X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { buildMobileSelectionContext, isMobileSelectionContextStale } from './mobile-selection-context'\nimport { MobileEditorContextBar } from './mobile-editor-context-bar'\nimport { MobileEditorMoreSheet } from './mobile-editor-more-sheet'\nimport { shouldRestorePendingQuote } from './quote-session'\nimport { getEditorContentContainerClass } from '@/lib/editor-layout-styles'\nimport { getResultIndexToFocus } from './search-navigation'\nimport './style.css'\n\nconst lowlight = createLowlight(common)\n\n// Helper function to convert 1-based line number to document position\nfunction lineToPosition(doc: ProseMirrorNode, line: number): number {\n  if (line <= 1) {\n    return 0\n  }\n\n  let pos = doc.content.size\n  let currentLine = 1\n\n  doc.descendants((node, nodePos) => {\n    if (!node.isTextblock) {\n      return true\n    }\n\n    if (currentLine === line) {\n      pos = nodePos + 1\n      return false\n    }\n\n    const blockText = node.textContent || ''\n    const lineBreaks = blockText.split('\\n').length - 1\n    currentLine += lineBreaks + 1\n\n    if (currentLine === line) {\n      pos = nodePos + 1\n      return false\n    }\n\n    return true\n  })\n\n  return pos\n}\n\n// 自定义扩展：处理粘贴 Markdown 文本\nconst PasteMarkdown = Extension.create({\n  name: 'pasteMarkdown',\n\n  addProseMirrorPlugins() {\n    const { editor } = this\n    return [\n      new Plugin({\n        props: {\n          handlePaste(_view, event, _slice) {\n            void _view\n            void _slice\n            const text = (event as ClipboardEvent).clipboardData?.getData('text/plain')\n\n            if (!text) {\n              return false\n            }\n\n            // 检查文本是否看起来像 Markdown\n            if (looksLikeMarkdown(text)) {\n              // 使用 editor.commands.insertContent 插入 Markdown 内容\n              editor.commands.insertContent(text, { contentType: 'markdown' })\n              return true\n            }\n\n            return false\n          },\n        },\n      }),\n    ]\n  },\n})\n\n\n// 简单的启发式函数：检查文本是否看起来像 Markdown\nfunction looksLikeMarkdown(text: string): boolean {\n  return (\n    /^#{1,6}\\s/.test(text) || // 标题\n    /\\*\\*[^*]+\\*\\*/.test(text) || // 粗体\n    /\\*[^*]+\\*/.test(text) || // 斜体\n    /\\[.+\\]\\(.+\\)/.test(text) || // 链接\n    /^[-*+]\\s/.test(text) || // 无序列表\n    /^\\d+\\.\\s/.test(text) || // 有序列表\n    /^>\\s/.test(text) || // 引用\n    /^```[\\s\\S]*```$/.test(text) || // 代码块\n    /`[^`]+`/.test(text) // 行内代码\n  )\n}\n\ninterface TipTapEditorProps {\n  initialContent: string\n  onChange?: (content: string) => void\n  placeholder?: string\n  editable?: boolean\n  activeFilePath?: string\n  onQuoteToChat?: () => void\n  onReady?: () => void\n  onEditorReady?: (editor: any) => void\n  outlineOpen?: boolean\n  onToggleOutline?: () => void\n  autoScroll?: boolean\n  showOverlay?: boolean\n  onTerminate?: () => void\n}\n\ntype MobileSelectionContext =\n  | {\n      mode: 'text'\n      from: number\n      to: number\n      previewText: string\n      actions: string[]\n    }\n  | {\n      mode: 'image'\n      pos: number\n      src: string\n      alt: string\n      actions: string[]\n    }\n  | {\n      mode: 'table'\n      from: number\n      actions: string[]\n    }\n  | null\n\ntype MobileSheetMode = 'ai' | 'image-src' | 'image-alt' | 'table-align' | 'table-more' | null\n\nexport function TipTapEditor({\n  initialContent,\n  onChange,\n  placeholder,\n  editable = true,\n  activeFilePath = '',\n  onQuoteToChat,\n  onReady,\n  onEditorReady,\n  outlineOpen,\n  onToggleOutline,\n  autoScroll = false,\n  showOverlay = false,\n  onTerminate,\n}: TipTapEditorProps) {\n  const t = useTranslations('editor')\n  const tMermaid = useTranslations('editor.mermaid.templates')\n  const tImage = useTranslations('editor.image')\n  const pendingQuote = useChatStore((state) => state.pendingQuote)\n  const pendingSearchKeyword = useArticleStore((state) => state.pendingSearchKeyword)\n  const setPendingSearchKeyword = useArticleStore((state) => state.setPendingSearchKeyword)\n\n  const placeholderText = placeholder || t('placeholder')\n  const isMobile = isMobileDevice()\n\n  // Use ref for autoScroll to avoid infinite re-render loop\n  const autoScrollRef = useRef(autoScroll)\n  autoScrollRef.current = autoScroll\n\n  // 获取正文缩放设置\n  const { contentTextScale } = useSettingStore()\n\n  // 居中内容设置\n  const [centeredContent, setCenteredContent] = useState(false)\n\n  // 编辑器容器 ref，用于应用字体缩放\n  const editorContainerRef = useRef<HTMLDivElement>(null)\n\n  // Math dialog state\n  const [mathDialogOpen, setMathDialogOpen] = useState(false)\n  const [mathType, setMathType] = useState<'inline' | 'block'>('inline')\n\n  // Search and replace panel state\n  const [searchReplaceOpen, setSearchReplaceOpen] = useState(false)\n  const [mobileContext, setMobileContext] = useState<MobileSelectionContext>(null)\n  const [mobileSheetMode, setMobileSheetMode] = useState<MobileSheetMode>(null)\n  const [imageSrcDraft, setImageSrcDraft] = useState('')\n  const [imageAltDraft, setImageAltDraft] = useState('')\n  const aiActionHandlersRef = useRef({\n    polish: async () => {},\n    concise: async () => {},\n    expand: async () => {},\n  })\n\n  const isInitializedRef = useRef(false)\n  const initializedForPathRef = useRef<string | null>(null)\n  const externalUpdateCounterRef = useRef(0)\n  const pendingSyncUpdateRef = useRef<{ path: string; content: string } | null>(null)\n\n  // 读取居中内容设置（移动端强制关闭）\n  useEffect(() => {\n    async function loadCenteredContent() {\n      // 移动端强制关闭居中内容\n      if (isMobileDevice()) {\n        setCenteredContent(false)\n        return\n      }\n      const store = await Store.load('store.json');\n      const centered = await store.get<boolean>('centeredContent') || false\n      setCenteredContent(centered)\n    }\n    loadCenteredContent()\n  }, [])\n  // Bug fix: Track when editor is ready (has caught up with content)\n  const isReadyRef = useRef(false)\n  // Bug fix: Track if this is the first onUpdate after initialization\n  const isFirstUpdateRef = useRef(true)\n\n  // Content version ref for race condition prevention between editor and agent\n  const contentVersionRef = useRef(0)\n\n  // When file path changes, reset initialization state to avoid old file content overwriting new file\n  useEffect(() => {\n    if (initializedForPathRef.current !== activeFilePath && activeFilePath) {\n      isInitializedRef.current = false\n      isReadyRef.current = false\n      isFirstUpdateRef.current = true\n      initializedForPathRef.current = activeFilePath\n      pendingSyncUpdateRef.current = null\n    }\n  }, [activeFilePath])\n\n  const editor = useEditor({\n    immediatelyRender: false,\n    extensions: [\n      StarterKit.configure({\n        heading: {\n          levels: [1, 2, 3, 4, 5, 6],\n        },\n        codeBlock: false,\n        link: false,\n        paragraph: false,\n        underline: false,\n      }),\n      MarkdownParagraph,\n      Placeholder.configure({\n        placeholder: placeholderText,\n        showOnlyCurrent: true,\n      }),\n      Link.configure({\n        openOnClick: false,\n      }),\n      TaskList,\n      TaskItem.configure({\n        nested: true,\n      }),\n      CodeBlockLowlight.configure({\n        lowlight,\n      }),\n      CharacterCount,\n      Highlight.configure({\n        multicolor: true,\n      }),\n      Underline,\n      TextAlign.configure({\n        types: ['heading', 'paragraph'],\n      }),\n      Typography,\n      SearchAndReplace,\n      Dropcursor,\n      Table.configure({\n        resizable: true,\n      }),\n      TableRow,\n      TableHeader,\n      TableCell,\n      Markdown.configure({\n        indentation: {\n          style: 'space',\n          size: 2,\n        },\n      }),\n      SlashCommand.configure({\n        suggestion: suggestionOptions,\n      }),\n      QuoteMark,\n      AISuggestion,\n      UniqueId.configure({\n        attributeName: 'data-id',\n        types: ['paragraph', 'heading', 'blockquote', 'codeBlock', 'listItem', 'bulletList', 'orderedList', 'taskItem', 'table', 'tableRow', 'tableCell', 'tableHeader'],\n      }),\n      InlineMath,\n      BlockMath,\n      MermaidDiagram,\n      Image.extend({\n        addAttributes() {\n          return {\n            ...this.parent?.(),\n            relativeSrc: {\n              default: null,\n              parseHTML: (element) => element.getAttribute('data-relative-src'),\n              renderHTML: (attributes) => {\n                return {\n                  'data-relative-src': attributes.relativeSrc,\n                }\n              },\n            },\n          }\n        },\n        parseHTML() {\n          return [\n            {\n              tag: 'img[src]',\n              getAttrs: (element) => {\n                const src = element.getAttribute('src')\n                const relativeSrc = element.getAttribute('data-relative-src') || src\n                const uploading = element.getAttribute('data-uploading') === 'true'\n                // 如果是相对路径（非 http/https/asset://），转换为 asset://\n                if (src && !src.startsWith('http') && !src.startsWith('asset://') && !src.startsWith('tauri://')) {\n                  // 这里不能直接调用 async 函数，需要在后续处理\n                  return {\n                    src, // 先保持原样，后续通过其他方式处理\n                    relativeSrc: src,\n                    alt: element.getAttribute('alt') || '',\n                    uploading,\n                  }\n                }\n                return {\n                  src,\n                  relativeSrc,\n                  alt: element.getAttribute('alt') || '',\n                  uploading,\n                }\n              },\n            },\n          ]\n        },\n        renderHTML({ node }) {\n          return ['img', {\n            src: node.attrs.src,\n            alt: node.attrs.alt || '',\n            class: 'max-w-full h-auto rounded-lg',\n            'data-relative-src': node.attrs.relativeSrc,\n          }]\n        },\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\n        renderMarkdown(node, _helpers) {\n          // 优先使用 relativeSrc，其次使用 src\n          const attrs = node.attrs || {}\n          let src = attrs.relativeSrc || attrs.src || ''\n          // 如果是 asset:// 或 tauri:// 路径，提取实际路径\n          src = src.replace(/^(tauri|asset|http):\\/\\/localhost\\//, '')\n          return `![${attrs.alt || ''}](${src})`\n        },\n        addInputRules() {\n          return [\n            nodeInputRule({\n              find: /!\\[(.*?)\\]\\((.*?)(?:\\s+\"(.*?)\")?\\)$/,\n              type: this.type,\n              getAttributes: (match) => {\n                const [, alt, src, title] = match\n                // 规范化路径：去掉 ./ 前缀\n                const normalizedSrc = src.replace(/^\\.\\//, '')\n                return { src: normalizedSrc, alt, title, relativeSrc: normalizedSrc }\n              },\n            }),\n          ]\n        },\n              }).configure({\n        inline: true,\n        allowBase64: false,\n        HTMLAttributes: {\n          class: 'max-w-full h-auto rounded-lg',\n        },\n      }),\n      // 自定义粘贴 Markdown 扩展\n      PasteMarkdown,\n    ],\n    content: initialContent,\n    contentType: 'markdown',\n    editable,\n    onUpdate: ({ editor }) => {\n      // Bug fix: Only trigger onChange if editor is ready (not during initialization)\n      // Using counter to handle rapid successive updates\n      if (externalUpdateCounterRef.current === 0 && isReadyRef.current) {\n        let markdown = editor.getMarkdown()\n        // 修复表格空单元格中的 &nbsp; 问题 - 替换为空格\n        markdown = markdown.replace(/&nbsp;/g, ' ')\n        onChange?.(markdown)\n        // Mark that we've processed the first update\n        isFirstUpdateRef.current = false\n        // Increment version on user content changes\n        contentVersionRef.current++\n      } else if (isFirstUpdateRef.current) {\n        // Skip the very first update during initialization\n      } else {\n        // Skip other updates (counter > 0 means external update)\n      }\n    },\n  })\n\n  // 处理编辑器内链接点击\n  useEffect(() => {\n    if (!editor || !editorContainerRef.current) return\n\n    const editorElement = editorContainerRef.current\n\n    const handleClick = (event: MouseEvent) => {\n      const target = event.target as HTMLElement\n      const anchor = target.closest('a')\n\n      if (!anchor) return\n\n      let href = anchor.getAttribute('href')\n      if (!href) return\n\n      // 阻止默认行为\n      event.preventDefault()\n      // 阻止事件冒泡，防止其他处理器触发\n      event.stopPropagation()\n\n      // 处理 file:// 协议\n      if (href.startsWith('file://')) {\n        href = href.replace(/^file:\\/\\//, '')\n        // Windows 路径处理\n        if (href.startsWith('/') && !href.match(/^[A-Z]:/)) {\n          href = href.substring(1)\n        }\n        openUrl(`file://${href}`).catch(console.error)\n        return\n      }\n\n      // 检查是否是本地开发服务器的 URL (localhost 或 127.0.0.1)\n      const isLocalUrl = href.match(/^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?\\//)\n\n      // 根据链接类型执行不同操作\n      if (href.startsWith('http://') || href.startsWith('https://')) {\n        if (isLocalUrl) {\n          // 本地开发服务器 URL，提取路径部分作为本地文件\n          const url = new URL(href)\n          let filePath = url.pathname\n          // 移除开头的斜杠（如果是 Unix 风格路径）\n          if (filePath.startsWith('/')) {\n            filePath = filePath.substring(1)\n          }\n          // Windows 路径处理\n          if (filePath.match(/^[A-Z]:/)) {\n            // 已经是 Windows 绝对路径\n          } else if (filePath.startsWith('/')) {\n            filePath = filePath.substring(1)\n          }\n          // URL 解码\n          filePath = decodeURIComponent(filePath)\n\n          // 获取当前文件的父目录，计算相对路径\n          const currentFilePath = useArticleStore.getState().activeFilePath\n          let fullPath: string\n\n          if (filePath.startsWith('/') || filePath.match(/^[A-Z]:/)) {\n            // 绝对路径\n            fullPath = filePath\n          } else {\n            // 相对路径，基于当前文件所在目录\n            const parentDir = currentFilePath.includes('/')\n              ? currentFilePath.substring(0, currentFilePath.lastIndexOf('/'))\n              : ''\n            fullPath = parentDir ? `${parentDir}/${filePath}` : filePath\n          }\n\n          // 在软件内部打开文件\n          useArticleStore.getState().setActiveFilePath(fullPath)\n          return\n        } else {\n          // 外部 HTTP/HTTPS 链接：用浏览器打开\n          openUrl(href).catch(console.error)\n          return\n        }\n      } else if (href.startsWith('mailto:') || href.startsWith('tel:')) {\n        // 邮件和电话链接，用默认应用打开\n        openUrl(href).catch(console.error)\n        return\n      } else {\n        // 本地路径相对路径，基于当前文件所在目录\n        const currentFilePath = useArticleStore.getState().activeFilePath\n        let fullPath: string\n\n        if (href.startsWith('/') || href.match(/^[A-Z]:/)) {\n          // 绝对路径\n          fullPath = href\n        } else {\n          // 相对路径\n          const parentDir = currentFilePath.includes('/')\n            ? currentFilePath.substring(0, currentFilePath.lastIndexOf('/'))\n            : ''\n          fullPath = parentDir ? `${parentDir}/${href}` : href\n        }\n\n        // 在软件内部打开文件\n        useArticleStore.getState().setActiveFilePath(fullPath)\n        return\n      }\n    }\n\n    editorElement.addEventListener('click', handleClick)\n\n    return () => {\n      editorElement.removeEventListener('click', handleClick)\n    }\n  }, [editor])\n\n  const restoreMobileContextSelection = useCallback((context: MobileSelectionContext = mobileContext) => {\n    if (!editor || !context) {\n      return false\n    }\n\n    const docSize = editor.state.doc.content.size\n    if (isMobileSelectionContextStale(context, docSize)) {\n      setMobileContext(null)\n      setMobileSheetMode(null)\n      return false\n    }\n\n    if (context.mode === 'text') {\n      editor.chain().focus().setTextSelection({ from: context.from, to: context.to }).run()\n      return true\n    }\n\n    if (context.mode === 'image') {\n      editor.chain().focus().setNodeSelection(context.pos).run()\n      return true\n    }\n\n    editor.chain().focus().setTextSelection(context.from).run()\n    return true\n  }, [editor, mobileContext])\n\n  const updateMobileContext = useCallback(() => {\n    if (!editor || !isMobile) {\n      setMobileContext(null)\n      return\n    }\n\n    const { from, to } = editor.state.selection\n    const selectedNode = editor.state.doc.nodeAt(from)\n\n    if (selectedNode?.type.name === 'image') {\n      const nextContext = buildMobileSelectionContext({\n        mode: 'image',\n        pos: from,\n        src: selectedNode.attrs.relativeSrc || selectedNode.attrs.src || '',\n        alt: selectedNode.attrs.alt || '',\n      }) as MobileSelectionContext\n      setImageSrcDraft(selectedNode.attrs.relativeSrc || selectedNode.attrs.src || '')\n      setImageAltDraft(selectedNode.attrs.alt || '')\n      setMobileContext(nextContext)\n      return\n    }\n\n    const previewText = editor.state.doc.textBetween(from, to).trim()\n    if (from !== to && previewText) {\n      const nextContext = buildMobileSelectionContext({\n        mode: 'text',\n        from,\n        to,\n        previewText,\n      }) as MobileSelectionContext\n      setMobileContext(nextContext)\n      return\n    }\n\n    if (editor.isActive('table')) {\n      const nextContext = buildMobileSelectionContext({\n        mode: 'table',\n        from,\n      }) as MobileSelectionContext\n      setMobileContext(nextContext)\n      return\n    }\n\n    setMobileContext(null)\n    setMobileSheetMode(null)\n  }, [editor, isMobile])\n\n  const runMobileEditorAction = useCallback((action: string) => {\n    if (!editor || !mobileContext) return\n\n    switch (action) {\n      case 'quote':\n        if (restoreMobileContextSelection()) {\n          onQuoteToChat?.()\n        }\n        return\n      case 'bold':\n        if (restoreMobileContextSelection()) {\n          editor.chain().focus().toggleBold().run()\n        }\n        return\n      case 'highlight':\n        if (restoreMobileContextSelection()) {\n          editor.chain().focus().toggleHighlight().run()\n        }\n        return\n      case 'ai':\n        setMobileSheetMode('ai')\n        return\n      case 'more':\n        setMobileSheetMode('table-more')\n        return\n      case 'image-src':\n        setMobileSheetMode('image-src')\n        return\n      case 'image-alt':\n        setMobileSheetMode('image-alt')\n        return\n      case 'delete-image':\n        if (restoreMobileContextSelection(mobileContext) && mobileContext.mode === 'image') {\n          editor.chain().focus().deleteRange({ from: mobileContext.pos, to: mobileContext.pos + 1 }).run()\n          updateMobileContext()\n        }\n        return\n      case 'add-row':\n        if (restoreMobileContextSelection()) {\n          editor.chain().focus().addRowAfter().run()\n          updateMobileContext()\n        }\n        return\n      case 'add-column':\n        if (restoreMobileContextSelection()) {\n          editor.chain().focus().addColumnAfter().run()\n          updateMobileContext()\n        }\n        return\n      case 'align':\n        setMobileSheetMode('table-align')\n        return\n      case 'ai-polish':\n        if (restoreMobileContextSelection()) {\n          setMobileSheetMode(null)\n          void aiActionHandlersRef.current.polish()\n        }\n        return\n      case 'ai-concise':\n        if (restoreMobileContextSelection()) {\n          setMobileSheetMode(null)\n          void aiActionHandlersRef.current.concise()\n        }\n        return\n      case 'ai-expand':\n        if (restoreMobileContextSelection()) {\n          setMobileSheetMode(null)\n          void aiActionHandlersRef.current.expand()\n        }\n        return\n      case 'italic':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleItalic().run()\n        return\n      case 'underline':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleUnderline().run()\n        return\n      case 'strike':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleStrike().run()\n        return\n      case 'code':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleCode().run()\n        return\n      case 'blockquote':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleBlockquote().run()\n        return\n      case 'bulletList':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleBulletList().run()\n        return\n      case 'orderedList':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleOrderedList().run()\n        return\n      case 'taskList':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleTaskList().run()\n        return\n      case 'codeBlock':\n        if (restoreMobileContextSelection()) editor.chain().focus().toggleCodeBlock().run()\n        return\n      case 'align-left':\n        if (restoreMobileContextSelection()) editor.chain().focus().setCellAttribute('align', 'left').run()\n        return\n      case 'align-center':\n        if (restoreMobileContextSelection()) editor.chain().focus().setCellAttribute('align', 'center').run()\n        return\n      case 'align-right':\n        if (restoreMobileContextSelection()) editor.chain().focus().setCellAttribute('align', 'right').run()\n        return\n      case 'add-row-before':\n        if (restoreMobileContextSelection()) editor.chain().focus().addRowBefore().run()\n        return\n      case 'add-row-after':\n        if (restoreMobileContextSelection()) editor.chain().focus().addRowAfter().run()\n        return\n      case 'add-column-before':\n        if (restoreMobileContextSelection()) editor.chain().focus().addColumnBefore().run()\n        return\n      case 'add-column-after':\n        if (restoreMobileContextSelection()) editor.chain().focus().addColumnAfter().run()\n        return\n      case 'delete-row':\n        if (restoreMobileContextSelection()) editor.chain().focus().deleteRow().run()\n        return\n      case 'delete-column':\n        if (restoreMobileContextSelection()) editor.chain().focus().deleteColumn().run()\n        return\n      case 'delete-table':\n        if (restoreMobileContextSelection()) editor.chain().focus().deleteTable().run()\n        return\n      default:\n        return\n    }\n  }, [\n    editor,\n    mobileContext,\n    onQuoteToChat,\n    restoreMobileContextSelection,\n    updateMobileContext,\n  ])\n\n  const submitMobileImageSrc = useCallback(() => {\n    if (!editor || !mobileContext || mobileContext.mode !== 'image') return\n    if (!restoreMobileContextSelection(mobileContext)) return\n\n    editor.chain().focus().updateAttributes('image', {\n      src: imageSrcDraft.trim(),\n      relativeSrc: imageSrcDraft.trim(),\n    }).run()\n    setMobileSheetMode(null)\n    updateMobileContext()\n  }, [editor, imageSrcDraft, mobileContext, restoreMobileContextSelection, updateMobileContext])\n\n  const submitMobileImageAlt = useCallback(() => {\n    if (!editor || !mobileContext || mobileContext.mode !== 'image') return\n    if (!restoreMobileContextSelection(mobileContext)) return\n\n    editor.chain().focus().updateAttributes('image', {\n      alt: imageAltDraft.trim(),\n    }).run()\n    setMobileSheetMode(null)\n    updateMobileContext()\n  }, [editor, imageAltDraft, mobileContext, restoreMobileContextSelection, updateMobileContext])\n\n  useEffect(() => {\n    if (!editor || !isMobile) return\n\n    updateMobileContext()\n    editor.on('selectionUpdate', updateMobileContext)\n    editor.on('transaction', updateMobileContext)\n\n    return () => {\n      editor.off('selectionUpdate', updateMobileContext)\n      editor.off('transaction', updateMobileContext)\n    }\n  }, [editor, isMobile, updateMobileContext])\n\n  useEffect(() => {\n    if (!editor) return\n\n    const quoteMarkType = editor.state.schema.marks.quote\n    if (!quoteMarkType) return\n\n    let tr = editor.state.tr\n    let changed = false\n\n    editor.state.doc.descendants((node, pos) => {\n      if (!node.isText) return true\n      if (node.marks.some((mark) => mark.type === quoteMarkType)) {\n        tr = tr.removeMark(pos, pos + node.nodeSize, quoteMarkType)\n        changed = true\n      }\n      return true\n    })\n\n    const quoteToRestore = pendingQuote\n    if (quoteToRestore && shouldRestorePendingQuote(quoteToRestore, activeFilePath, editor.state.doc.content.size)) {\n      tr = tr.addMark(quoteToRestore.from, quoteToRestore.to, quoteMarkType.create())\n      changed = true\n    }\n\n    if (changed) {\n      editor.view.dispatch(tr)\n    }\n  }, [editor, pendingQuote, activeFilePath])\n\n  useEffect(() => {\n    if (!editor || !isMobile) return\n\n    const editorDom = editor.view.dom\n    const handleMobileImageClick = (event: Event) => {\n      const target = event.target as HTMLElement | null\n      if (!target || target.tagName !== 'IMG') return\n\n      const pos = editor.view.posAtDOM(target, 0)\n      editor.chain().focus().setNodeSelection(pos).run()\n      updateMobileContext()\n    }\n\n    editorDom.addEventListener('click', handleMobileImageClick)\n    return () => {\n      editorDom.removeEventListener('click', handleMobileImageClick)\n    }\n  }, [editor, isMobile, updateMobileContext])\n\n  // Auto scroll to bottom when content changes and autoScroll is enabled\n  useEffect(() => {\n    if (!editor) return\n\n    // Use requestAnimationFrame to avoid infinite loop\n    let isScrolling = false\n\n    const scrollToBottom = () => {\n      if (!autoScrollRef.current || isScrolling) return\n      isScrolling = true\n\n      requestAnimationFrame(() => {\n        try {\n          if (editorContainerRef.current) {\n            const proseMirror = editorContainerRef.current.querySelector('.ProseMirror') as HTMLElement\n            if (proseMirror) {\n              proseMirror.scrollTop = proseMirror.scrollHeight\n            }\n          }\n        } finally {\n          isScrolling = false\n        }\n      })\n    }\n\n    // Listen to editor updates\n    editor.on('update', scrollToBottom)\n\n    return () => {\n      editor.off('update', scrollToBottom)\n    }\n  }, [editor])\n\n  // 应用正文文字大小缩放\n  useEffect(() => {\n    if (!editor) return\n\n    const applyFontSize = () => {\n      if (editorContainerRef.current) {\n        const proseMirror = editorContainerRef.current.querySelector('.ProseMirror') as HTMLElement\n        if (proseMirror) {\n          // 使用 16px 作为基础字体大小，根据 contentTextScale 进行缩放\n          const baseFontSize = 16\n          proseMirror.style.fontSize = `${(baseFontSize * contentTextScale) / 100}px`\n        }\n      }\n    }\n\n    // 立即应用一次\n    applyFontSize()\n  }, [contentTextScale, editor])\n\n  // Track active file path for image uploads (ref to avoid re-initializing editor)\n  const activeFilePathRef = useRef(activeFilePath)\n  useEffect(() => {\n    activeFilePathRef.current = activeFilePath\n  }, [activeFilePath])\n\n  // Handle image paste and drop\n  useEffect(() => {\n    // Check if editor is fully initialized\n    if (!editor || !editor.view || !editor.view.dom) return\n\n    const handlePaste = (event: ClipboardEvent) => {\n      const files = event.clipboardData?.files\n      if (!files || files.length === 0) return\n\n      const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'))\n      if (imageFiles.length === 0) return\n\n      const imageFile = imageFiles[0]\n\n      // Prevent default to avoid base64 image being inserted\n      event.preventDefault()\n\n      // Insert \"Uploading...\" text as placeholder\n      const { from } = editor.state.selection\n\n      editor.chain()\n        .focus()\n        .insertContentAt(from, {\n          type: 'text',\n          text: 'Uploading... ',\n        })\n        .run()\n\n      // Get the position range of the placeholder\n      const placeholderStart = from\n      const placeholderEnd = from + 'Uploading... '.length\n\n      handleImageUpload(imageFile, activeFilePathRef.current)\n        .then(result => {\n          // Delete the placeholder text\n          editor.chain()\n            .focus()\n            .deleteRange({ from: placeholderStart, to: placeholderEnd })\n            .run()\n\n          // Insert the actual image\n          editor.chain()\n            .insertContentAt(placeholderStart, {\n              type: 'image',\n              attrs: {\n                src: result.src,\n                alt: imageFile.name,\n                relativeSrc: result.relativePath,\n              },\n            })\n            .run()\n        })\n        .catch(error => {\n          // Remove the placeholder on error\n          editor.chain()\n            .focus()\n            .deleteRange({ from: placeholderStart, to: placeholderEnd })\n            .run()\n\n          // Show error toast\n          console.error('Image upload failed:', error)\n          toast({\n            title: tImage('failed'),\n            description: error instanceof Error ? error.message : undefined,\n            variant: 'destructive',\n          })\n        })\n    }\n\n    const handleDrop = (event: DragEvent) => {\n      const files = event.dataTransfer?.files\n      if (!files || files.length === 0) return\n\n      const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'))\n      if (imageFiles.length === 0) return\n\n      const imageFile = imageFiles[0]\n\n      // Prevent default to avoid base64 image being inserted\n      event.preventDefault()\n\n      // Get drop position\n      const pos = editor.view.posAtCoords({ left: event.clientX, top: event.clientY })\n      const insertPos = pos?.pos || editor.state.selection.from\n\n      // Insert \"Uploading...\" text as placeholder\n      editor.chain()\n        .focus()\n        .insertContentAt(insertPos, {\n          type: 'text',\n          text: 'Uploading... ',\n        })\n        .run()\n\n      // Get the position range of the placeholder\n      const placeholderStart = insertPos\n      const placeholderEnd = insertPos + 'Uploading... '.length\n\n      handleImageUpload(imageFile, activeFilePathRef.current)\n        .then(result => {\n          // Delete the placeholder text\n          editor.chain()\n            .focus()\n            .deleteRange({ from: placeholderStart, to: placeholderEnd })\n            .run()\n\n          // Insert the actual image\n          editor.chain()\n            .insertContentAt(placeholderStart, {\n              type: 'image',\n              attrs: {\n                src: result.src,\n                alt: imageFile.name,\n                relativeSrc: result.relativePath,\n              },\n            })\n            .run()\n        })\n        .catch(error => {\n          // Remove the placeholder on error\n          editor.chain()\n            .focus()\n            .deleteRange({ from: placeholderStart, to: placeholderEnd })\n            .run()\n\n          // Show error toast\n          console.error('Image upload failed:', error)\n          toast({\n            title: tImage('failed'),\n            description: error instanceof Error ? error.message : undefined,\n            variant: 'destructive',\n          })\n        })\n    }\n\n    // Add event listeners to editor DOM element\n    // Check if editor is fully initialized first\n    if (!editor.view || !editor.view.dom) return\n    const dom = editor.view.dom\n    dom.addEventListener('paste', handlePaste as EventListener)\n    dom.addEventListener('drop', handleDrop as EventListener)\n\n    return () => {\n      dom.removeEventListener('paste', handlePaste as EventListener)\n      dom.removeEventListener('drop', handleDrop as EventListener)\n    }\n  }, [editor])\n\n  // Handle copy event to output Markdown format\n  useEffect(() => {\n    // Check if editor is fully initialized\n    if (!editor || !editor.view || !editor.view.dom) return\n\n    const handleCopy = (event: ClipboardEvent) => {\n      const { from, to } = editor.state.selection\n\n      // If there's no selection, let browser handle the default copy\n      if (from === to) {\n        return\n      }\n\n      // Check if markdown extension is available\n      if (!editor.markdown) {\n        return\n      }\n\n      // Get the selected content as Markdown\n      const slice = editor.state.doc.slice(from, to)\n      // Wrap in doc node for proper serialization\n      const json = { type: 'doc', content: slice.content.toJSON() }\n      const markdown = editor.markdown.serialize(json)\n\n      // Write Markdown to clipboard\n      if (event.clipboardData) {\n        event.clipboardData.setData('text/plain', markdown)\n        event.preventDefault()\n      }\n    }\n\n    const dom = editor.view.dom\n    dom.addEventListener('copy', handleCopy as EventListener)\n\n    return () => {\n      dom.removeEventListener('copy', handleCopy as EventListener)\n    }\n  }, [editor])\n\n  // Handle AI Polish - improve selected text (with streaming and suggestion mode)\n  const handleAIPolish = useCallback(async () => {\n    if (!editor) return\n\n    const { from, to } = editor.state.selection\n    const selectedText = editor.state.doc.textBetween(from, to)\n\n    if (!selectedText.trim()) {\n      return\n    }\n\n    // Create abort controller for this request\n    const controller = new AbortController()\n\n    // Delete original text and start streaming\n    editor.chain()\n      .focus()\n      .deleteSelection()\n      .run()\n\n    // Get initial position and start streaming immediately\n    const initialCoords = editor.view.coordsAtPos(editor.state.selection.from)\n    emitter.emit('start-ai-streaming', {\n      originalText: selectedText,\n      type: 'polish',\n      position: initialCoords,\n      controller,\n    })\n\n    // Track accumulated result\n    let accumulatedResult = ''\n    const startPosition = editor.state.selection.from\n\n    try {\n      await fetchAiPolishStream(\n        selectedText,\n        (chunk) => {\n          // Insert chunk as plain text during streaming\n          editor.chain()\n            .insertContentAt(startPosition + accumulatedResult.length, chunk)\n            .run()\n\n          // Update tracking\n          accumulatedResult += chunk\n\n          // Update floating menu with streaming content and position\n          const coords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n          emitter.emit('update-ai-streaming-content', {\n            suggestedText: accumulatedResult,\n            position: coords,\n          })\n        },\n        controller.signal\n      )\n\n      // Streaming complete - replace all content with proper Markdown parsing\n      editor.chain()\n        .deleteRange({ from: startPosition, to: startPosition + accumulatedResult.length })\n        .insertContent(accumulatedResult, { contentType: 'markdown' })\n        .run()\n\n      // Send completion event\n      const finalCoords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n      emitter.emit('ai-streaming-complete', {\n        originalText: selectedText,\n        suggestedText: accumulatedResult,\n        type: 'polish',\n        position: finalCoords,\n        generatedRange: { from: startPosition, to: startPosition + accumulatedResult.length },\n      })\n      emitter.emit('onboarding-step-complete', { step: 'ai-polish' })\n    } catch (error) {\n      if (error instanceof Error && error.name === 'AbortError') {\n        return\n      }\n      // Restore original text on error\n      editor.chain()\n        .focus()\n        .insertContent(selectedText)\n        .run()\n      emitter.emit('ai-streaming-complete')\n    }\n  }, [editor])\n\n  // Handle AI Concise - simplify selected text (with streaming and suggestion mode)\n  const handleAIConcise = useCallback(async () => {\n    if (!editor) return\n\n    const { from, to } = editor.state.selection\n    const selectedText = editor.state.doc.textBetween(from, to)\n\n    if (!selectedText.trim()) {\n      return\n    }\n\n    // Create abort controller for this request\n    const controller = new AbortController()\n\n    // Delete original text and start streaming\n    editor.chain()\n      .focus()\n      .deleteSelection()\n      .run()\n\n    // Get initial position and start streaming immediately\n    const initialCoords = editor.view.coordsAtPos(editor.state.selection.from)\n    emitter.emit('start-ai-streaming', {\n      originalText: selectedText,\n      type: 'concise',\n      position: initialCoords,\n      controller,\n    })\n\n    // Track accumulated result\n    let accumulatedResult = ''\n    const startPosition = editor.state.selection.from\n\n    try {\n      await fetchAiConciseStream(\n        selectedText,\n        (chunk) => {\n          // Insert chunk as plain text during streaming\n          editor.chain()\n            .insertContentAt(startPosition + accumulatedResult.length, chunk)\n            .run()\n\n          // Update tracking\n          accumulatedResult += chunk\n\n          // Update floating menu with streaming content and position\n          const coords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n          emitter.emit('update-ai-streaming-content', {\n            suggestedText: accumulatedResult,\n            position: coords,\n          })\n        },\n        controller.signal\n      )\n\n      // Streaming complete - replace all content with proper Markdown parsing\n      editor.chain()\n        .deleteRange({ from: startPosition, to: startPosition + accumulatedResult.length })\n        .insertContent(accumulatedResult, { contentType: 'markdown' })\n        .run()\n\n      // Send completion event\n      const finalCoords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n      emitter.emit('ai-streaming-complete', {\n        originalText: selectedText,\n        suggestedText: accumulatedResult,\n        type: 'concise',\n        position: finalCoords,\n        generatedRange: { from: startPosition, to: startPosition + accumulatedResult.length },\n      })\n      emitter.emit('onboarding-step-complete', { step: 'ai-polish' })\n    } catch (error) {\n      if (error instanceof Error && error.name === 'AbortError') {\n        return\n      }\n      // Restore original text on error\n      editor.chain()\n        .focus()\n        .insertContent(selectedText)\n        .run()\n      emitter.emit('ai-streaming-complete')\n    }\n  }, [editor])\n\n  // Handle AI Expand - expand selected text (with streaming and suggestion mode)\n  const handleAIExpand = useCallback(async () => {\n    if (!editor) return\n\n    const { from, to } = editor.state.selection\n    const selectedText = editor.state.doc.textBetween(from, to)\n\n    if (!selectedText.trim()) {\n      return\n    }\n\n    // Create abort controller for this request\n    const controller = new AbortController()\n\n    // Delete original text and start streaming\n    editor.chain()\n      .focus()\n      .deleteSelection()\n      .run()\n\n    // Get initial position and start streaming immediately\n    const initialCoords = editor.view.coordsAtPos(editor.state.selection.from)\n    emitter.emit('start-ai-streaming', {\n      originalText: selectedText,\n      type: 'expand',\n      position: initialCoords,\n      controller,\n    })\n\n    // Track accumulated result\n    let accumulatedResult = ''\n    const startPosition = editor.state.selection.from\n\n    try {\n      await fetchAiExpandStream(\n        selectedText,\n        (chunk) => {\n          // Insert chunk as plain text during streaming\n          editor.chain()\n            .insertContentAt(startPosition + accumulatedResult.length, chunk)\n            .run()\n\n          // Update tracking\n          accumulatedResult += chunk\n\n          // Update floating menu with streaming content and position\n          const coords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n          emitter.emit('update-ai-streaming-content', {\n            suggestedText: accumulatedResult,\n            position: coords,\n          })\n        },\n        controller.signal\n      )\n\n      // Streaming complete - replace all content with proper Markdown parsing\n      editor.chain()\n        .deleteRange({ from: startPosition, to: startPosition + accumulatedResult.length })\n        .insertContent(accumulatedResult, { contentType: 'markdown' })\n        .run()\n\n      // Send completion event\n      const finalCoords = editor.view.coordsAtPos(startPosition + accumulatedResult.length)\n      emitter.emit('ai-streaming-complete', {\n        originalText: selectedText,\n        suggestedText: accumulatedResult,\n        type: 'expand',\n        position: finalCoords,\n        generatedRange: { from: startPosition, to: startPosition + accumulatedResult.length },\n      })\n      emitter.emit('onboarding-step-complete', { step: 'ai-polish' })\n    } catch (error) {\n      if (error instanceof Error && error.name === 'AbortError') {\n        return\n      }\n      // Restore original text on error\n      editor.chain()\n        .focus()\n        .insertContent(selectedText)\n        .run()\n      emitter.emit('ai-streaming-complete')\n    }\n  }, [editor])\n\n  useEffect(() => {\n    aiActionHandlersRef.current = {\n      polish: handleAIPolish,\n      concise: handleAIConcise,\n      expand: handleAIExpand,\n    }\n  }, [handleAIPolish, handleAIConcise, handleAIExpand])\n\n  // Initialize content only once - preserves undo/redo history when switching tabs\n  // Bug fix: Only initialize if the editor is for the current file path\n  useEffect(() => {\n    if (!editor || !activeFilePath) return\n\n    // Check if this is still the correct file path (handle race conditions)\n    const currentPath = activeFilePath\n\n    // Only initialize on first mount - subsequent content changes should not overwrite\n    // user edits (e.g., when switching back to a previously edited tab)\n    // Bug fix: Also check that we're initializing for the correct file path\n    if (!isInitializedRef.current) {\n      // Use setTimeout to avoid flushSync conflict during React render\n      setTimeout(() => {\n        // Check if the file path is still the same (handle race condition)\n        if (activeFilePath !== currentPath) return\n\n        if (initialContent) {\n          editor.commands.setContent(initialContent || '', { contentType: 'markdown' })\n        }\n        // Mark as initialized to allow subsequent content updates\n        isInitializedRef.current = true\n        // Bug fix: Mark editor as ready AFTER content is set\n        // This prevents onUpdate from firing with empty content during init\n        isReadyRef.current = true\n        // Notify mobile editor that editor is ready\n        onReady?.()\n        // Notify parent component about editor instance\n        onEditorReady?.(editor)\n      }, 0)\n    }\n  }, [editor, initialContent, onReady, onEditorReady, activeFilePath])\n\n  // 处理编辑器中图片的相对路径，转换为 asset:// URL\n  useEffect(() => {\n    if (!editor || !editor.view) return\n\n    const transformImagePaths = () => {\n      // 获取编辑器 DOM 中的所有图片\n      const editorDom = editor.view.dom\n      const images = editorDom.querySelectorAll('img')\n\n      // 获取当前文件的父目录，用于计算相对路径\n      const currentFilePath = useArticleStore.getState().activeFilePath\n      const parentDir = currentFilePath?.includes('/')\n        ? currentFilePath.substring(0, currentFilePath.lastIndexOf('/'))\n        : ''\n\n      for (const img of images) {\n        const src = img.getAttribute('src')\n        // 如果是相对路径，转换为 asset://\n        if (src && !src.startsWith('http') && !src.startsWith('asset://') && !src.startsWith('tauri://')) {\n          // 计算完整的相对路径（基于当前文件所在目录）\n          const fullRelativePath = parentDir ? `${parentDir}/${src}` : src\n          // 异步转换路径\n          convertImageByWorkspace(fullRelativePath).then((assetUrl: string) => {\n            // 只有当 src 仍然是相对路径时才更新（避免覆盖已转换的）\n            const currentSrc = img.getAttribute('src')\n            if (currentSrc === src || !currentSrc?.startsWith('asset://')) {\n              img.setAttribute('src', assetUrl)\n            }\n          })\n        }\n        // 添加 onerror 处理：如果加载失败，尝试转换路径\n        if (img && !img.onerror) {\n          img.onerror = async () => {\n            const currentSrc = img.getAttribute('src')\n            if (currentSrc && !currentSrc.startsWith('http') && !currentSrc.startsWith('asset://') && !currentSrc.startsWith('tauri://')) {\n              // 计算完整的相对路径（基于当前文件所在目录）\n              const fullRelativePath = parentDir ? `${parentDir}/${currentSrc}` : currentSrc\n              const assetUrl = await convertImageByWorkspace(fullRelativePath)\n              img.setAttribute('src', assetUrl)\n            }\n          }\n        }\n      }\n    }\n\n    // 监听 transaction 事件 - 在文档更新时立即转换\n    const handleTransaction = () => {\n      transformImagePaths()\n    }\n\n    // 监听 selectionUpdate 事件\n    const handleSelectionUpdate = () => {\n      transformImagePaths()\n    }\n\n    editor.on('transaction', handleTransaction)\n    editor.on('selectionUpdate', handleSelectionUpdate)\n\n    // 初始执行\n    transformImagePaths()\n\n    return () => {\n      editor.off('transaction', handleTransaction)\n      editor.off('selectionUpdate', handleSelectionUpdate)\n    }\n  }, [editor])\n\n  // Listen to editor updates and notify TabBar about undo/redo state\n  useEffect(() => {\n    if (!editor) return\n\n    const handleUpdate = () => {\n      emitter.emit('editor-undo-redo-changed', {\n        undo: editor.can().undo(),\n        redo: editor.can().redo()\n      })\n    }\n\n    editor.on('update', handleUpdate)\n    return () => {\n      editor.off('update', handleUpdate)\n    }\n  }, [editor, activeFilePath])\n\n  // Listen for search trigger from layout (Ctrl+F / Cmd+F)\n  useEffect(() => {\n    const handleSearchTrigger = () => {\n      setSearchReplaceOpen(true)\n    }\n\n    emitter.on('editor-search-trigger' as any, handleSearchTrigger)\n    return () => {\n      emitter.off('editor-search-trigger' as any, handleSearchTrigger)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!editor || !activeFilePath || !pendingSearchKeyword.trim()) {\n      return\n    }\n\n    let cancelled = false\n    let readyRetryTimer: ReturnType<typeof setTimeout> | null = null\n    let focusTimer: ReturnType<typeof setTimeout> | null = null\n    let readyAttempts = 0\n    const maxReadyAttempts = 20\n\n    const applyPendingSearch = () => {\n      if (cancelled) return\n\n      if (!isInitializedRef.current || !isReadyRef.current) {\n        if (readyAttempts >= maxReadyAttempts) {\n          setPendingSearchKeyword('')\n          return\n        }\n        readyAttempts += 1\n        readyRetryTimer = setTimeout(applyPendingSearch, 50)\n        return\n      }\n\n      const storage = (editor.storage as any).searchAndReplace\n      if (!storage) {\n        setPendingSearchKeyword('')\n        return\n      }\n\n      storage.searchTerm = pendingSearchKeyword\n      editor.view.dispatch(editor.state.tr)\n\n      focusTimer = setTimeout(() => {\n        if (cancelled) return\n\n        const results = storage.results || []\n        const resultIndex = getResultIndexToFocus(results, 0)\n\n        if (resultIndex === -1) {\n          setPendingSearchKeyword('')\n          return\n        }\n\n        storage.resultIndex = resultIndex\n        const result = results[resultIndex]\n        if (!result) {\n          setPendingSearchKeyword('')\n          return\n        }\n\n        const selection = TextSelection.near(editor.state.doc.resolve(result.from))\n        editor.view.dispatch(editor.state.tr.setSelection(selection))\n        editor.commands.scrollIntoView()\n\n        setTimeout(() => {\n          const domPos = editor.view.domAtPos(result.from)\n          if (domPos.node instanceof Element) {\n            domPos.node.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          } else if (domPos.node.parentElement) {\n            domPos.node.parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' })\n          }\n        }, 0)\n\n        setPendingSearchKeyword('')\n      }, 0)\n    }\n\n    applyPendingSearch()\n\n    return () => {\n      cancelled = true\n      if (readyRetryTimer) clearTimeout(readyRetryTimer)\n      if (focusTimer) clearTimeout(focusTimer)\n    }\n  }, [editor, activeFilePath, pendingSearchKeyword, setPendingSearchKeyword, initialContent])\n\n  // Handle remote file pull updates via event (instead of initialContent change)\n  // This fixes cursor jump issue caused by unnecessary setContent during local saves\n  useEffect(() => {\n    const handleRemoteContentUpdate = (event: { content: string }) => {\n      if (!editor || !event?.content) return\n\n      const currentContent = editor.getMarkdown()\n      const newContent = event.content\n\n      // Only update if content actually changed\n      if (newContent !== currentContent) {\n        isReadyRef.current = false\n        externalUpdateCounterRef.current++\n        setTimeout(() => {\n          editor.commands.setContent(newContent, { contentType: 'markdown' })\n          isReadyRef.current = true\n          setTimeout(() => {\n            externalUpdateCounterRef.current = Math.max(0, externalUpdateCounterRef.current - 1)\n          }, 100)\n        }, 0)\n      }\n    }\n\n    emitter.on('editor-content-from-remote', handleRemoteContentUpdate as any)\n    return () => {\n      emitter.off('editor-content-from-remote', handleRemoteContentUpdate as any)\n    }\n  }, [editor, activeFilePath])\n\n  // NOTE: Removed initialContent useEffect that caused cursor jump during local edits\n  // Remote pull is now handled via 'editor-content-from-remote' event\n  // Sync and external updates are handled by their respective events\n\n  // Handle sync content updated from auto-sync\n  useEffect(() => {\n    const handleSyncContentUpdated = (event: { path: string; content: string }) => {\n      // Bug fix: Only update if this is the active file\n      if (!editor || !event || event.path !== activeFilePath) return\n\n      // Bug fix: Skip if content hasn't actually changed\n      const currentContent = editor.getMarkdown()\n      if (currentContent === event.content) return\n\n      // Bug fix: Set pending update and verify path when processing\n      pendingSyncUpdateRef.current = event\n\n      // Bug fix: Mark editor as not ready during update\n      isReadyRef.current = false\n      externalUpdateCounterRef.current++\n      // Use setTimeout to avoid flushSync conflict during React render\n      setTimeout(() => {\n        editor.commands.setContent(event.content, { contentType: 'markdown' })\n        // Bug fix: Mark editor as ready after content is set\n        isReadyRef.current = true\n        // Reset the counter and pending update after a short delay\n        setTimeout(() => {\n          // Only reset if this is still the same pending update\n          if (pendingSyncUpdateRef.current === event) {\n            pendingSyncUpdateRef.current = null\n          }\n          externalUpdateCounterRef.current = Math.max(0, externalUpdateCounterRef.current - 1)\n        }, 100)\n      }, 0)\n    }\n\n    emitter.on('sync-content-updated', handleSyncContentUpdated as any)\n    return () => {\n      emitter.off('sync-content-updated', handleSyncContentUpdated as any)\n    }\n  }, [editor, activeFilePath])\n\n  // Handle external content updates (e.g., from Agent tools)\n  useEffect(() => {\n    const handleExternalUpdate = (newContent: string) => {\n      if (editor && externalUpdateCounterRef.current === 0) {\n        // Bug fix: Skip if content hasn't actually changed\n        const currentContent = editor.getMarkdown()\n        if (currentContent === newContent) return\n\n        // Bug fix: Mark editor as not ready during update\n        isReadyRef.current = false\n        // Set counter first to prevent circular updates\n        externalUpdateCounterRef.current++\n        // Use setTimeout to avoid flushSync conflict during React render\n        setTimeout(() => {\n          // Set content in editor with Markdown parsing\n          editor.commands.setContent(newContent, { contentType: 'markdown' })\n          // Bug fix: Mark editor as ready after content is set\n          isReadyRef.current = true\n          // Reset the counter after a short delay to handle rapid updates\n          setTimeout(() => {\n            externalUpdateCounterRef.current = Math.max(0, externalUpdateCounterRef.current - 1)\n          }, 100)\n        }, 0)\n      }\n    }\n\n    emitter.on('external-content-update', handleExternalUpdate as any)\n    return () => {\n      emitter.off('external-content-update', handleExternalUpdate as any)\n    }\n  }, [editor])\n\n  // Set editable state\n  useEffect(() => {\n    editor?.setEditable(editable)\n  }, [editable, editor])\n\n  // Handle AI continue writing\n  useEffect(() => {\n    let abortController: AbortController | null = null\n\n    const handleAIContinue = async () => {\n      if (!editor) return\n\n      // Get content before cursor as context\n      const { from } = editor.state.selection\n      const textBefore = editor.state.doc.textBetween(0, from, '\\n')\n\n      // Get last 500 characters as context\n      const context = textBefore.slice(-500)\n\n      if (!context.trim()) {\n        toast({\n          title: '续写失败',\n          description: '请先输入一些内容',\n          variant: 'destructive',\n        })\n        return\n      }\n\n      // Create new AbortController for this request\n      abortController = new AbortController()\n\n      // Insert loading indicator at cursor position\n      const loadingMark = editor.state.schema.marks.strong\n      if (!loadingMark) {\n        // If no strong mark available, insert simple text\n        editor.chain().focus().insertContent('...').run()\n      } else {\n        editor.chain().focus().insertContent('···').run()\n      }\n\n      // Track accumulated result for streaming\n      let accumulatedResult = ''\n      const startPosition = from\n\n      try {\n        await fetchCompletionStream(\n          context,\n          (chunk, isFirst) => {\n            if (isFirst) {\n              // Delete the loading indicator before inserting first chunk\n              const { to } = editor.state.selection\n              editor.chain().focus().deleteRange({ from: to - 3, to }).run()\n            }\n            // Insert chunk as plain text during streaming\n            editor.chain().focus().insertContent(chunk).run()\n            accumulatedResult += chunk\n          },\n          abortController.signal\n        )\n\n        // Streaming complete - replace content with proper Markdown parsing\n        if (accumulatedResult) {\n          editor.chain()\n            .deleteRange({ from: startPosition, to: startPosition + accumulatedResult.length })\n            .insertContent(accumulatedResult, { contentType: 'markdown' })\n            .run()\n        }\n      } catch (error) {\n        // Delete loading indicator on error\n        const { to } = editor.state.selection\n        editor.chain().focus().deleteRange({ from: to - 3, to }).run()\n\n        // Show error toast (but not for aborted requests)\n        if (error instanceof Error && error.message !== 'Request was aborted.') {\n          toast({\n            title: '续写失败',\n            description: error.message || '网络错误',\n            variant: 'destructive',\n          })\n        }\n      }\n    }\n\n    document.addEventListener('tiptap-ai-continue', handleAIContinue)\n    return () => {\n      document.removeEventListener('tiptap-ai-continue', handleAIContinue)\n      abortController?.abort()\n    }\n  }, [editor])\n\n  // Handle drag and drop from marks\n  const handleEditorDrop = useCallback((e: React.DragEvent) => {\n    const markData = e.dataTransfer.getData('application/json')\n    if (markData) {\n      try {\n        const mark = JSON.parse(markData)\n        if (mark && mark.id !== undefined) {\n          import('@/lib/mark-to-markdown').then(({ markToMarkdown }) => {\n            const markdown = markToMarkdown(mark)\n            editor?.commands.insertContent(markdown, { contentType: 'markdown' })\n            toast({\n              title: '已插入记录',\n              description: mark.desc || mark.content?.slice(0, 50) || '记录内容'\n            })\n          })\n        }\n      } catch (error) {\n        console.error('Failed to parse dropped mark:', error)\n      }\n    }\n  }, [editor])\n\n  // Handle math formula insertion from slash menu\n  useEffect(() => {\n    if (!editor) return\n\n    const handleInsertInlineMath = () => {\n      setMathType('inline')\n      setMathDialogOpen(true)\n    }\n\n    const handleInsertBlockMath = () => {\n      setMathType('block')\n      setMathDialogOpen(true)\n    }\n\n    document.addEventListener('tiptap-insert-inline-math', handleInsertInlineMath)\n    document.addEventListener('tiptap-insert-block-math', handleInsertBlockMath)\n\n    return () => {\n      document.removeEventListener('tiptap-insert-inline-math', handleInsertInlineMath)\n      document.removeEventListener('tiptap-insert-block-math', handleInsertBlockMath)\n    }\n  }, [editor])\n\n  // Handle math dialog insert\n  const handleMathInsert = useCallback((latex: string, type: 'inline' | 'block') => {\n    if (!editor) return\n\n    if (type === 'inline') {\n      editor.chain().focus().insertContent({\n        type: 'inlineMath',\n        attrs: { latex },\n      }).run()\n    } else {\n      editor.chain().focus().insertContent({\n        type: 'blockMath',\n        attrs: { latex },\n      }).run()\n    }\n  }, [editor])\n\n  // Editor tools event handlers for Agent integration\n  useEffect(() => {\n    // Get editor selection\n    const handleGetSelection = ({ resolve }: { resolve: (data: { text: string; from: number; to: number; html?: string; startLine?: number; endLine?: number }) => void }) => {\n      if (!editor) {\n        resolve({ text: '', from: 0, to: 0, startLine: 1, endLine: 1 })\n        return\n      }\n\n      const { from, to } = editor.state.selection\n      const text = editor.state.doc.textBetween(from, to)\n\n      // Calculate line numbers (1-indexed) by counting newlines before position\n      const textBeforeFrom = editor.state.doc.textBetween(0, from, '\\n', '\\n')\n      const startLine = (textBeforeFrom.match(/\\n/g)?.length || 0) + 1\n\n      const textBeforeTo = editor.state.doc.textBetween(0, to, '\\n', '\\n')\n      const endLine = (textBeforeTo.match(/\\n/g)?.length || 0) + 1\n\n      resolve({\n        text,\n        from,\n        to,\n        html: editor.getHTML(),\n        startLine,\n        endLine,\n      })\n    }\n\n    // Get editor content\n    const handleGetContent = ({ resolve }: { resolve: (data: { markdown: string; html?: string; text: string; wordCount: number; charCount: number; totalLines?: number; numberedLines?: string; version: number }) => void }) => {\n      if (!editor) {\n        resolve({ markdown: '', text: '', wordCount: 0, charCount: 0, totalLines: 1, numberedLines: '1 | ', version: 0 })\n        return\n      }\n\n      let markdown = editor.getMarkdown()\n      // 修复表格空单元格中的 &nbsp; 问题 - 替换为空格\n      markdown = markdown.replace(/&nbsp;/g, ' ')\n      const text = editor.getText()\n      const html = editor.getHTML()\n      const markdownLines = markdown.split('\\n')\n      const totalLines = markdownLines.length\n      const lineNumberWidth = String(totalLines).length\n      const numberedLines = markdownLines\n        .map((line, index) => `${String(index + 1).padStart(lineNumberWidth)} | ${line}`)\n        .join('\\n')\n\n      resolve({\n        markdown,\n        html,\n        text,\n        wordCount: text.split(/\\s+/).filter(w => w).length,\n        charCount: text.length,\n        totalLines,\n        numberedLines,\n        version: contentVersionRef.current,\n      })\n    }\n\n    // Insert content at cursor\n    const handleInsert = ({ content, resolve }: { content: string; resolve: (result: { success: boolean; insertedLength: number; newCursorPosition?: number }) => void }) => {\n      if (!editor) {\n        resolve({ success: false, insertedLength: 0 })\n        return\n      }\n\n      try {\n        // Insert content with markdown parsing\n        // Wrap in setTimeout to avoid React lifecycle flushSync conflict\n        setTimeout(() => {\n          editor.commands.insertContent(content, { contentType: 'markdown' })\n\n          // Use the actual cursor position after transaction\n          const newPosition = editor.state.selection.from\n\n          resolve({\n            success: true,\n            insertedLength: content.length,\n            newCursorPosition: newPosition,\n          })\n        }, 0)\n      // eslint-disable-next-line @typescript-eslint/no-unused-vars\n      } catch (error) {\n        resolve({ success: false, insertedLength: 0 })\n      }\n    }\n\n    // Replace content in range\n    const handleReplace = ({\n      content,\n      range,\n      searchContent,\n      occurrence,\n      startLine,\n      endLine,\n      expectedVersion,\n      resolve,\n    }: {\n      content?: string\n      range?: { from: number; to: number }\n      searchContent?: string\n      occurrence?: number\n      startLine?: number\n      endLine?: number\n      expectedVersion?: number\n      resolve: (result: { success: boolean; insertedLength: number; message?: string; error?: string; newCursorPosition?: number; versionMismatch?: boolean }) => void\n    }) => {\n      if (!editor) {\n        resolve({ success: false, insertedLength: 0, error: 'Editor not initialized' })\n        return\n      }\n\n      // Verify version if provided\n      if (expectedVersion !== undefined && expectedVersion !== contentVersionRef.current) {\n        resolve({ success: false, versionMismatch: true, insertedLength: 0, error: 'Content has changed, please get editor content again' })\n        return\n      }\n\n      try {\n        let { from, to } = editor.state.selection\n\n        // Mode 1: Position-based (use current selection if not specified)\n        if (range) {\n          from = range.from\n          to = range.to\n        }\n        // Mode 2: Text-based search\n        else if (searchContent) {\n          // Try to find searchContent in the document using a more robust method\n          const doc = editor.state.doc\n          const content = editor.state.doc.textContent\n          const searchLower = searchContent.toLowerCase()\n          const contentLower = content.toLowerCase()\n\n          // Count occurrences to find the target one\n          let currentOccurrence = 0\n          let searchFrom = 0\n          let foundIndex = -1\n\n          while (currentOccurrence < (occurrence || 1)) {\n            foundIndex = contentLower.indexOf(searchLower, searchFrom)\n            if (foundIndex === -1) {\n              resolve({ success: false, insertedLength: 0, error: `找不到文本 \"${searchContent}\"` })\n              return\n            }\n            currentOccurrence++\n            searchFrom = foundIndex + 1\n          }\n\n          // Now find the exact position in the ProseMirror doc\n          // Use ProseMirror's descendant traversal to find text position\n          let foundFrom = -1\n          let foundTo = -1\n\n          doc.descendants((node, pos) => {\n            if (foundFrom !== -1) return false // Already found, stop traversal\n\n            if (node.isText && node.text) {\n              const idxInNode = node.text.toLowerCase().indexOf(searchLower)\n              if (idxInNode !== -1) {\n                foundFrom = pos + idxInNode\n                foundTo = foundFrom + searchContent.length\n                return false // Stop traversal\n              }\n            }\n          })\n\n          if (foundFrom === -1) {\n            // Fallback: use approximate position from markdown\n            foundFrom = foundIndex\n            foundTo = foundIndex + searchContent.length\n          }\n\n          from = foundFrom\n          to = foundTo\n        }\n        // Mode 3: Line-based\n        else if (startLine !== undefined && endLine !== undefined) {\n          const doc = editor.state.doc\n          // Convert 1-based line numbers to positions\n          from = lineToPosition(doc, startLine)\n          to = lineToPosition(doc, endLine + 1)\n        }\n        // Fallback: use current selection (only if content is provided)\n        else if (content) {\n          // Don't change from/to, use current selection\n        } else {\n          resolve({ success: false, insertedLength: 0, error: '请提供 content、range、searchContent 或 startLine/endLine 参数' })\n          return\n        }\n\n        const newContent = content || ''\n\n        // Delete old content and insert new content with markdown parsing\n        // Wrap in setTimeout to avoid React lifecycle flushSync conflict\n        setTimeout(() => {\n          editor.chain()\n            .focus()\n            .deleteRange({ from, to })\n            .insertContent(newContent, { contentType: 'markdown' })\n            .run()\n\n          // Increment version after successful replacement\n          contentVersionRef.current++\n\n          resolve({\n            success: true,\n            insertedLength: newContent.length,\n            message: `成功替换 ${to - from} 个字符为 ${newContent.length} 个字符`,\n            newCursorPosition: from + newContent.length,\n          })\n        }, 0)\n      } catch (error) {\n        resolve({ success: false, insertedLength: 0, error: String(error) })\n      }\n    }\n\n    // Get quote from editor for chat\n    const handleGetQuote = () => {\n      if (!editor) return\n      const { from, to } = editor.state.selection\n      if (from !== to) {\n        const quote = editor.state.doc.textBetween(from, to)\n        const fileName = activeFilePath?.split('/').pop() || ''\n        const textBeforeFrom = editor.state.doc.textBetween(0, from, '\\n', '\\n')\n        const startLine = (textBeforeFrom.match(/\\n/g)?.length || 0) + 1\n\n        const textBeforeTo = editor.state.doc.textBetween(0, to, '\\n', '\\n')\n        const endLine = (textBeforeTo.match(/\\n/g)?.length || 0) + 1\n        const markdownLines = editor.getMarkdown().split('\\n')\n        const quotedMarkdown = markdownLines.slice(startLine - 1, endLine).join('\\n')\n\n        const quoteData = {\n          quote,\n          fullContent: quotedMarkdown || quote,\n          fileName,\n          startLine,\n          endLine,\n          from,\n          to,\n          articlePath: activeFilePath || '',\n        }\n\n        useChatStore.getState().setPendingQuote(quoteData)\n        emitter.emit('insert-quote', quoteData)\n      }\n    }\n\n    // Track if listeners have been set up (for cleanup)\n    let listenersSetup = false\n\n    // Handle Mermaid diagram insertion\n    const handleInsertMermaid = (event: CustomEvent) => {\n      if (!editor) return\n      const { type } = event.detail || {}\n\n      // Get template from i18n\n      const getTemplate = (diagramType: string) => {\n        return tMermaid(diagramType) || tMermaid('flowchart')\n      }\n\n      const code = getTemplate(type || 'flowchart')\n\n      // Insert mermaid diagram node\n      editor.chain().focus().insertContent({\n        type: 'mermaidDiagram',\n        attrs: { code, type: type || 'flowchart' },\n      }).run()\n    }\n\n    // Handle undo/redo from TabBar buttons\n    const handleUndo = () => {\n      if (!editor) return\n      editor.chain().focus().undo().run()\n    }\n\n    const handleRedo = () => {\n      if (!editor) return\n      editor.chain().focus().redo().run()\n    }\n\n    // Handle query for undo/redo capability\n    const handleCanUndoRedo = ({ resolve }: { resolve: (can: { undo: boolean; redo: boolean }) => void }) => {\n      if (!editor) {\n        resolve({ undo: false, redo: false })\n        return\n      }\n      resolve({\n        undo: editor.can().undo(),\n        redo: editor.can().redo()\n      })\n    }\n\n    // Defer emitter and document listener registration to avoid flushSync conflict during React render\n    const setupListeners = () => {\n      // Check if editor is initialized before registering listeners\n      if (!editor) return\n\n      emitter.on('editor-get-selection', handleGetSelection)\n      emitter.on('editor-get-content', handleGetContent)\n      emitter.on('editor-insert', handleInsert)\n      emitter.on('editor-replace', handleReplace)\n      emitter.on('get-quote-from-editor', handleGetQuote)\n      emitter.on('editor-undo', handleUndo)\n      emitter.on('editor-redo', handleRedo)\n      emitter.on('editor-can-undo-redo', handleCanUndoRedo)\n      document.addEventListener('tiptap-insert-mermaid', handleInsertMermaid as EventListener)\n      listenersSetup = true\n    }\n\n    const cleanupListeners = () => {\n      emitter.off('editor-get-selection', handleGetSelection)\n      emitter.off('editor-get-content', handleGetContent)\n      emitter.off('editor-insert', handleInsert)\n      emitter.off('editor-replace', handleReplace)\n      emitter.off('get-quote-from-editor', handleGetQuote)\n      emitter.off('editor-undo', handleUndo)\n      emitter.off('editor-redo', handleRedo)\n      emitter.off('editor-can-undo-redo', handleCanUndoRedo)\n      // Only remove event listener if it was actually added\n      if (listenersSetup) {\n        document.removeEventListener('tiptap-insert-mermaid', handleInsertMermaid as EventListener)\n        listenersSetup = false\n      }\n    }\n\n    // Register listeners synchronously\n    if (editor) {\n      setupListeners()\n    }\n\n    return cleanupListeners\n  }, [editor, activeFilePath])\n\n  if (!editor) {\n    return null\n  }\n\n  return (\n    <div ref={editorContainerRef} id=\"aritcle-md-editor\" className=\"tiptap-editor relative flex flex-col h-full\">\n      {isMobile && mobileContext && (\n        <MobileEditorContextBar\n          mode={mobileContext.mode}\n          previewText={mobileContext.mode === 'text' ? mobileContext.previewText : undefined}\n          activeActions={mobileContext.actions}\n          onAction={runMobileEditorAction}\n        />\n      )}\n\n      {/* Editor content - scrollable area */}\n      <div\n        className=\"flex-1 overflow-x-hidden overflow-y-auto relative\"\n        onDragOver={(e) => e.preventDefault()}\n        onDrop={handleEditorDrop}\n      >\n        <div className={getEditorContentContainerClass({ centeredContent, isMobile })}>\n        <EditorContent editor={editor} className=\"h-full relative\">\n          {!isMobile && <ImageBubbleMenu editor={editor} />}\n\n          <AISuggestionFloating editor={editor} />\n\n          {!isMobile && <FloatingTableMenu editor={editor} />}\n\n          {!isMobile && (\n            <BubbleMenuComponent\n              editor={editor}\n              onAIPolish={handleAIPolish}\n              onAIConcise={handleAIConcise}\n              onAIExpand={handleAIExpand}\n              onQuoteToChat={onQuoteToChat}\n            />\n          )}\n        </EditorContent>\n\n        <SearchReplacePanel\n          editor={editor}\n          open={searchReplaceOpen}\n          onOpenChange={setSearchReplaceOpen}\n        />\n        </div>\n      </div>\n\n      {isMobile && (\n        <MobileEditorMoreSheet\n          open={mobileSheetMode !== null}\n          mode={mobileSheetMode}\n          imageSrc={imageSrcDraft}\n          imageAlt={imageAltDraft}\n          onOpenChange={(open) => {\n            if (!open) {\n              setMobileSheetMode(null)\n            }\n          }}\n          onImageSrcChange={setImageSrcDraft}\n          onImageAltChange={setImageAltDraft}\n          onSubmitImageSrc={submitMobileImageSrc}\n          onSubmitImageAlt={submitMobileImageAlt}\n          onAction={runMobileEditorAction}\n        />\n      )}\n\n      {/* AI Generation Overlay */}\n      {showOverlay && (\n        <div className=\"absolute inset-0 z-50 flex items-start justify-end p-4 bg-background/20 pointer-events-none\">\n          <div className=\"flex items-center gap-2 bg-background/90 border rounded-md px-3 py-2 shadow-md pointer-events-auto\">\n            <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />\n            <span className=\"text-xs text-muted-foreground\">AI 整理中</span>\n            {onTerminate && (\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={onTerminate}\n              >\n                <X className=\"size-3\" />\n              </Button>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Bottom toolbar - always visible */}\n      <FooterBar\n        editor={editor}\n        outlineOpen={outlineOpen}\n        onToggleOutline={onToggleOutline}\n      />\n\n      <SlashCommandPortal />\n\n      <MathEditorDialog\n        open={mathDialogOpen}\n        onOpenChange={setMathDialogOpen}\n        onInsert={handleMathInsert}\n        type={mathType}\n        title={mathType === 'inline' ? '插入行内公式' : '插入块级公式'}\n      />\n    </div>\n  )\n}\n\nexport default TipTapEditor\n"
  },
  {
    "path": "src/app/core/main/editor/onboarding-state.ts",
    "content": "export type OnboardingStepId = 'create-record' | 'organize-note' | 'ai-polish'\nexport type OnboardingCompletionFeedbackMode = 'inline' | 'dialog'\n\nexport interface OnboardingProgress {\n  dismissed: boolean\n  steps: Record<OnboardingStepId, boolean>\n}\n\nconst ONBOARDING_STEP_ORDER: OnboardingStepId[] = [\n  'create-record',\n  'organize-note',\n  'ai-polish',\n]\n\nexport function createDefaultOnboardingProgress(): OnboardingProgress {\n  return {\n    dismissed: false,\n    steps: {\n      'create-record': false,\n      'organize-note': false,\n      'ai-polish': false,\n    },\n  }\n}\n\nexport function normalizeOnboardingProgress(value: unknown): OnboardingProgress {\n  const defaults = createDefaultOnboardingProgress()\n\n  if (!value || typeof value !== 'object') {\n    return defaults\n  }\n\n  const candidate = value as Partial<OnboardingProgress>\n  const candidateSteps = candidate.steps && typeof candidate.steps === 'object'\n    ? candidate.steps as Partial<Record<OnboardingStepId, boolean>>\n    : {}\n\n  return {\n    dismissed: candidate.dismissed === true,\n    steps: {\n      'create-record': candidateSteps['create-record'] === true,\n      'organize-note': candidateSteps['organize-note'] === true,\n      'ai-polish': candidateSteps['ai-polish'] === true,\n    },\n  }\n}\n\nexport function markOnboardingStepDone(\n  progress: OnboardingProgress,\n  step: OnboardingStepId\n): OnboardingProgress {\n  return {\n    ...progress,\n    steps: {\n      ...progress.steps,\n      [step]: true,\n    },\n  }\n}\n\nexport function getActiveOnboardingStep(progress: OnboardingProgress): OnboardingStepId | null {\n  return ONBOARDING_STEP_ORDER.find((step) => !progress.steps[step]) ?? null\n}\n\nexport function getNextOnboardingStep(\n  progress: OnboardingProgress,\n  completedStep: OnboardingStepId | null\n): OnboardingStepId | null {\n  if (completedStep) {\n    return null\n  }\n\n  return getActiveOnboardingStep(progress)\n}\n\nexport function isOnboardingComplete(progress: OnboardingProgress): boolean {\n  return ONBOARDING_STEP_ORDER.every((step) => progress.steps[step])\n}\n\nexport function shouldShowOnboardingTasks(progress: OnboardingProgress): boolean {\n  return !progress.dismissed && !isOnboardingComplete(progress)\n}\n\nexport function getCompletionFeedbackMode(\n  completedStep: OnboardingStepId,\n  activeStep: OnboardingStepId | null\n): OnboardingCompletionFeedbackMode {\n  if (completedStep === 'organize-note' && activeStep === 'organize-note') {\n    return 'dialog'\n  }\n\n  return 'inline'\n}\n"
  },
  {
    "path": "src/app/core/main/editor/tab-bar.tsx",
    "content": "'use client'\n\nimport { useCallback, useRef, useState, useEffect, memo } from 'react'\nimport { X, FileText, Folder, Plus, Undo2, Redo2 } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { cn } from '@/lib/utils'\nimport emitter from '@/lib/emitter'\nimport { TooltipButton } from '@/components/tooltip-button'\nimport {\n  DndContext,\n  closestCenter,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  SortableContext,\n  sortableKeyboardCoordinates,\n  horizontalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuTrigger,\n} from '@/components/ui/enhanced-context-menu'\nimport { platform } from '@tauri-apps/plugin-os'\nimport useSettingStore from '@/stores/setting'\n\nexport interface TabInfo {\n  id: string\n  path: string\n  name: string\n  isFolder: boolean\n}\n\ninterface TabBarProps {\n  tabs: TabInfo[]\n  activeTabId: string\n  onTabSwitch: (path: string) => void\n  onNewTab: () => void\n  onCloseTab: (path: string) => void\n  onCloseOtherTabs: (path: string) => void\n  onCloseAllTabs: () => void\n  onCloseLeftTabs: (path: string) => void\n  onCloseRightTabs: (path: string) => void\n  showUndoRedo?: boolean // 保留这个 prop 以保持兼容性，但主要使用 store 中的值\n}\n\n// Sortable Tab with Context Menu\nfunction SortableTabWithMenu({\n  tab,\n  isActive,\n  tabs,\n  modKey,\n  onTabSwitch,\n  onCloseTab,\n  onCloseOtherTabs,\n  onCloseAllTabs,\n  onCloseLeftTabs,\n  onCloseRightTabs,\n}: {\n  tab: TabInfo\n  isActive: boolean\n  tabs: TabInfo[]\n  modKey: string\n  onTabSwitch: (path: string) => void\n  onCloseTab: (path: string) => void\n  onCloseOtherTabs: (path: string) => void\n  onCloseAllTabs: () => void\n  onCloseLeftTabs: (path: string) => void\n  onCloseRightTabs: (path: string) => void\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: tab.id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  const t = useTranslations('tabContext')\n  const currentIndex = tabs.findIndex(t => t.id === tab.id)\n  const canCloseLeft = currentIndex > 0\n  const canCloseRight = currentIndex < tabs.length - 1\n  const hasOthers = tabs.length > 1\n\n  const handleAction = (action: 'close' | 'closeOthers' | 'closeAll' | 'closeLeft' | 'closeRight') => {\n    switch (action) {\n      case 'close':\n        onCloseTab(tab.path)\n        break\n      case 'closeOthers':\n        onCloseOtherTabs(tab.path)\n        break\n      case 'closeAll':\n        onCloseAllTabs()\n        break\n      case 'closeLeft':\n        onCloseLeftTabs(tab.path)\n        break\n      case 'closeRight':\n        onCloseRightTabs(tab.path)\n        break\n    }\n  }\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger asChild>\n        <div\n          ref={setNodeRef}\n          style={style}\n          data-tab-id={tab.id}\n          className={cn(\n            'group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer transition-all shrink-0',\n            isActive\n              ? 'text-foreground font-medium'\n              : 'text-muted-foreground hover:text-foreground'\n          )}\n          title={tab.path}\n          onClick={() => onTabSwitch(tab.path)}\n          {...attributes}\n          {...listeners}\n        >\n          {tab.isFolder ? (\n            <Folder className=\"w-4 h-4 shrink-0 text-amber-500\" />\n          ) : (\n            <FileText className={cn('w-4 h-4 shrink-0', isActive ? 'text-primary' : '')} />\n          )}\n          <span className=\"truncate max-w-40\">{tab.name}</span>\n\n          {/* Close button */}\n          <button\n            onClick={(e) => {\n              e.stopPropagation()\n              onCloseTab(tab.path)\n            }}\n            className={cn(\n              'p-1 rounded transition-all shrink-0 ml-1',\n              'opacity-0 group-hover:opacity-100',\n              'hover:bg-muted'\n            )}\n          >\n            <X className=\"w-3 h-3\" />\n          </button>\n\n          {/* Active indicator line */}\n          {isActive && (\n            <div className=\"absolute -bottom-px left-0 right-0 h-0.5 bg-primary\" />\n          )}\n        </div>\n      </ContextMenuTrigger>\n\n      <ContextMenuContent className=\"w-48\">\n        <ContextMenuItem onClick={() => handleAction('close')}>\n          {t('close')}\n          <ContextMenuShortcut>{modKey}W</ContextMenuShortcut>\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem\n          onClick={() => handleAction('closeOthers')}\n          disabled={!hasOthers}\n        >\n          {t('closeOthers')}\n        </ContextMenuItem>\n        <ContextMenuItem\n          onClick={() => handleAction('closeLeft')}\n          disabled={!canCloseLeft}\n        >\n          {t('closeLeft')}\n        </ContextMenuItem>\n        <ContextMenuItem\n          onClick={() => handleAction('closeRight')}\n          disabled={!canCloseRight}\n        >\n          {t('closeRight')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem\n          onClick={() => handleAction('closeAll')}\n          disabled={tabs.length === 0}\n        >\n          {t('closeAll')}\n        </ContextMenuItem>\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n}\n\n// Memoize to prevent unnecessary re-renders\nconst MemoizedSortableTabWithMenu = memo(SortableTabWithMenu)\n\nexport function TabBar({\n  tabs,\n  activeTabId,\n  onTabSwitch,\n  onNewTab,\n  onCloseTab,\n  onCloseOtherTabs,\n  onCloseAllTabs,\n  onCloseLeftTabs,\n  onCloseRightTabs,\n}: TabBarProps) {\n  const { showEditorUndoRedo } = useSettingStore()\n\n  const scrollContainerRef = useRef<HTMLDivElement>(null)\n  const [scrollState, setScrollState] = useState({ left: 0, width: 0, scrollWidth: 0 })\n  const [canUndo, setCanUndo] = useState(false)\n  const [canRedo, setCanRedo] = useState(false)\n\n  // Query undo/redo capability from editor\n  const queryCanUndoRedo = useCallback(() => {\n    emitter.emit('editor-can-undo-redo', {\n      resolve: (can) => {\n        setCanUndo(can.undo)\n        setCanRedo(can.redo)\n      }\n    })\n  }, [])\n\n  // Query on mount and when activeTabId changes\n  useEffect(() => {\n    queryCanUndoRedo()\n  }, [activeTabId, queryCanUndoRedo])\n\n  // Listen for undo/redo state changes from editor\n  useEffect(() => {\n    const handleUndoRedoChanged = (can: { undo: boolean; redo: boolean }) => {\n      setCanUndo(can.undo)\n      setCanRedo(can.redo)\n    }\n\n    emitter.on('editor-undo-redo-changed', handleUndoRedoChanged)\n    return () => {\n      emitter.off('editor-undo-redo-changed', handleUndoRedoChanged)\n    }\n  }, [])\n\n  // Get current platform\n  const [currentPlatform, setCurrentPlatform] = useState<'macos' | 'windows' | 'linux' | 'unknown'>('unknown')\n  useEffect(() => {\n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch {\n      setCurrentPlatform('unknown')\n    }\n  }, [])\n\n  // Keyboard shortcut for closing tab (Cmd/Ctrl + W)\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      const isMac = currentPlatform === 'macos'\n      const modKey = isMac ? e.metaKey : e.ctrlKey\n\n      // Cmd/Ctrl + W: Close current tab\n      if (modKey && e.key === 'w' && activeTabId) {\n        e.preventDefault()\n        onCloseTab(tabs.find(t => t.id === activeTabId)?.path || '')\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [currentPlatform, activeTabId, tabs, onCloseTab])\n\n  const t = useTranslations('tabContext')\n\n  // Get modifier key display text\n  const modKey = currentPlatform === 'macos' ? '⌘' : 'Ctrl'\n\n  // Dnd sensors\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    })\n  )\n\n  // Update scroll state\n  const updateScrollState = useCallback(() => {\n    if (scrollContainerRef.current) {\n      const { scrollLeft, clientWidth, scrollWidth } = scrollContainerRef.current\n      setScrollState({ left: scrollLeft, width: clientWidth, scrollWidth })\n    }\n  }, [])\n\n  useEffect(() => {\n    updateScrollState()\n    const container = scrollContainerRef.current\n    if (container) {\n      container.addEventListener('scroll', updateScrollState)\n      const resizeObserver = new ResizeObserver(updateScrollState)\n      resizeObserver.observe(container)\n      return () => {\n        container.removeEventListener('scroll', updateScrollState)\n        resizeObserver.disconnect()\n      }\n    }\n  }, [updateScrollState, tabs])\n\n  // Scroll active tab into view\n  useEffect(() => {\n    if (!activeTabId || !scrollContainerRef.current) return\n\n    const tabElement = document.querySelector(`[data-tab-id=\"${activeTabId}\"]`) as HTMLElement\n    if (!tabElement) return\n\n    const container = scrollContainerRef.current\n    const tabRect = tabElement.getBoundingClientRect()\n    const containerRect = container.getBoundingClientRect()\n\n    // Check if tab is outside the visible area\n    const isOutside =\n      tabRect.right > containerRect.right ||\n      tabRect.left < containerRect.left\n\n    if (isOutside) {\n      // Calculate scroll position to center the tab\n      const scrollLeft = tabRect.left - containerRect.left + container.scrollLeft\n      container.scrollTo({\n        left: scrollLeft,\n        behavior: 'smooth'\n      })\n    }\n  }, [activeTabId, tabs])\n\n  const handleDragEnd = useCallback((event: DragEndEvent) => {\n    const { active, over } = event\n    if (over && active.id !== over.id) {\n      // Let parent handle reordering via callback\n    }\n  }, [])\n\n  // Handle wheel scroll to horizontal scroll\n  const handleWheel = useCallback((e: React.WheelEvent) => {\n    if (scrollContainerRef.current) {\n      e.preventDefault()\n      scrollContainerRef.current.scrollLeft += e.deltaY\n    }\n  }, [])\n\n  if (tabs.length === 0) {\n    return null\n  }\n\n  // Calculate scrollbar thumb position and width\n  const showScrollbar = scrollState.scrollWidth > scrollState.width\n  const thumbWidth = showScrollbar\n    ? Math.max(20, (scrollState.width / scrollState.scrollWidth) * 100)\n    : 0\n  const thumbLeft = showScrollbar\n    ? (scrollState.left / (scrollState.scrollWidth - scrollState.width)) * (100 - thumbWidth)\n    : 0\n\n  return (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={closestCenter}\n      onDragEnd={handleDragEnd}\n    >\n      <div className=\"relative tab-scrollbar-wrapper\">\n        <div className=\"flex items-center h-12 bg-background border-b\">\n          {/* Undo/Redo buttons - fixed on the left */}\n          {showEditorUndoRedo && (\n            <div className=\"flex items-center gap-0.5 px-2 border-r border-border shrink-0\">\n              <TooltipButton\n                icon={<Undo2 className=\"w-4 h-4\" />}\n                tooltipText={`撤销 (${modKey}+Z)`}\n                side=\"bottom\"\n                onClick={() => {\n                  emitter.emit('editor-undo')\n                  // Update state after action\n                  setTimeout(queryCanUndoRedo, 0)\n                }}\n                disabled={!canUndo}\n              />\n              <TooltipButton\n                icon={<Redo2 className=\"w-4 h-4\" />}\n                tooltipText={`重做 (${modKey}+Shift+Z)`}\n                side=\"bottom\"\n                onClick={() => {\n                  emitter.emit('editor-redo')\n                  // Update state after action\n                  setTimeout(queryCanUndoRedo, 0)\n                }}\n                disabled={!canRedo}\n              />\n            </div>\n          )}\n\n          {/* Tabs scroll container */}\n          <div\n            ref={scrollContainerRef}\n            className=\"flex items-center h-12 px-1 overflow-x-auto tab-scrollbar gap-1\"\n            onWheel={handleWheel}\n          >\n            {/* Tabs */}\n            <SortableContext\n              items={tabs.map(t => t.id)}\n              strategy={horizontalListSortingStrategy}\n            >\n              {tabs.map((tab) => (\n                <MemoizedSortableTabWithMenu\n                  key={tab.id}\n                  tab={tab}\n                  isActive={activeTabId === tab.id}\n                  tabs={tabs}\n                  modKey={modKey}\n                  onTabSwitch={onTabSwitch}\n                  onCloseTab={onCloseTab}\n                  onCloseOtherTabs={onCloseOtherTabs}\n                  onCloseAllTabs={onCloseAllTabs}\n                  onCloseLeftTabs={onCloseLeftTabs}\n                  onCloseRightTabs={onCloseRightTabs}\n                />\n              ))}\n            </SortableContext>\n\n            {/* New tab button */}\n            <button\n              onClick={onNewTab}\n              className=\"flex items-center justify-center w-8 h-8 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors shrink-0\"\n              title={t('closeAll')}\n            >\n              <Plus className=\"w-4 h-4\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Custom absolute scrollbar */}\n        {showScrollbar && (\n          <div className=\"tab-scrollbar-track\">\n            <div\n              className=\"tab-scrollbar-thumb\"\n              style={{\n                width: `${thumbWidth}%`,\n                left: `${thumbLeft}%`,\n              }}\n            />\n          </div>\n        )}\n      </div>\n    </DndContext>\n  )\n}\n\nexport default TabBar\n"
  },
  {
    "path": "src/app/core/main/editor/unsupported-file.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { File, FolderOpen, ExternalLink } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { toast } from '@/hooks/use-toast'\nimport { openPath } from '@tauri-apps/plugin-opener'\nimport { appDataDir } from '@tauri-apps/api/path'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\n\ninterface FileMetadata {\n  size: number\n  modifiedAt: number | null\n  createdAt: number | null\n}\n\ninterface UnsupportedFileProps {\n  filePath: string\n}\n\nexport function UnsupportedFile({ filePath }: UnsupportedFileProps) {\n  const t = useTranslations('article.unsupportedFile')\n  const [metadata, setMetadata] = useState<FileMetadata | null>(null)\n  const [loading, setLoading] = useState(true)\n  const [fullPath, setFullPath] = useState('')\n\n  const fileName = filePath.split('/').pop() || filePath\n\n  // 获取完整文件路径\n  useEffect(() => {\n    const fetchFullPath = async () => {\n      try {\n        const workspace = await getWorkspacePath()\n        if (workspace.isCustom) {\n          setFullPath(workspace.path + '/' + filePath)\n        } else {\n          const appDir = await appDataDir()\n          setFullPath(appDir + '/article/' + filePath)\n        }\n      } catch (error) {\n        console.error('Failed to get full path:', error)\n      }\n    }\n    fetchFullPath()\n  }, [filePath])\n\n  // 获取文件元信息\n  useEffect(() => {\n    const fetchMetadata = async () => {\n      try {\n        const { stat } = await import('@tauri-apps/plugin-fs')\n        const pathOptions = await getFilePathOptions(filePath)\n\n        let fileStat\n        if (pathOptions.baseDir) {\n          fileStat = await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n        } else {\n          fileStat = await stat(pathOptions.path)\n        }\n\n        setMetadata({\n          size: fileStat.size,\n          modifiedAt: fileStat.mtime?.getTime() || null,\n          createdAt: fileStat.birthtime?.getTime() || null\n        })\n      } catch (error) {\n        console.error('Failed to get file metadata:', error)\n      } finally {\n        setLoading(false)\n      }\n    }\n\n    fetchMetadata()\n  }, [filePath])\n\n  // 格式化文件大小\n  const formatFileSize = (bytes: number): string => {\n    if (bytes === 0) return '0 B'\n    const k = 1024\n    const sizes = ['B', 'KB', 'MB', 'GB', 'TB']\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]\n  }\n\n  // 格式化时间\n  const formatDate = (timestamp: number | null): string => {\n    if (!timestamp) return '-'\n    return new Date(timestamp).toLocaleString()\n  }\n\n  // 用外部程序打开\n  const handleOpenExternal = async () => {\n    try {\n      const workspace = await getWorkspacePath()\n\n      if (workspace.isCustom) {\n        // 自定义工作区：使用绝对路径\n        const pathOptions = await getFilePathOptions(filePath)\n        await openPath(pathOptions.path)\n      } else {\n        // 默认工作区：使用 AppData 目录\n        const appDir = await appDataDir()\n        const { join } = await import('@tauri-apps/api/path')\n        await openPath(await join(appDir, 'article', filePath))\n      }\n    } catch (error) {\n      console.error('Failed to open file:', error)\n    }\n  }\n\n  // 打开文件目录\n  const handleOpenDirectory = async () => {\n    try {\n      const workspace = await getWorkspacePath()\n      const folderPath = filePath.substring(0, filePath.lastIndexOf('/'))\n\n      if (workspace.isCustom) {\n        const pathOptions = await getFilePathOptions(folderPath)\n        await openPath(pathOptions.path)\n      } else {\n        const appDir = await appDataDir()\n        const { join } = await import('@tauri-apps/api/path')\n        await openPath(await join(appDir, 'article', folderPath))\n      }\n    } catch (error) {\n      console.error('Failed to open directory:', error)\n    }\n  }\n\n  return (\n    <div className=\"flex-1 flex flex-col items-center justify-center h-full bg-background p-8\">\n      <div className=\"max-w-lg w-full space-y-6\">\n        {/* 文件名和图标 */}\n        <div className=\"flex items-center gap-3\">\n          <File className=\"w-8 h-8 text-muted-foreground\" />\n          <div className=\"flex-1 min-w-0\">\n            <h2 className=\"text-lg font-semibold truncate\">{fileName}</h2>\n            <p\n  className=\"text-sm text-muted-foreground truncate cursor-pointer hover:text-primary transition-colors\"\n  title={fullPath || filePath}\n  onClick={async () => {\n    await navigator.clipboard.writeText(fullPath || filePath)\n    toast({ title: t('pathCopied') || '路径已复制' })\n  }}\n>\n  {fullPath || filePath}\n</p>\n          </div>\n        </div>\n\n        {/* 元信息 */}\n        <div className=\"bg-card rounded-lg border p-4 space-y-3\">\n          <div className=\"flex justify-between\">\n            <span className=\"text-sm text-muted-foreground\">{t('fileSize')}</span>\n            <span className=\"text-sm font-medium\">\n              {loading ? '...' : (metadata ? formatFileSize(metadata.size) : '-')}\n            </span>\n          </div>\n          <div className=\"flex justify-between\">\n            <span className=\"text-sm text-muted-foreground\">{t('modifiedTime')}</span>\n            <span className=\"text-sm font-medium\">\n              {loading ? '...' : formatDate(metadata?.modifiedAt || null)}\n            </span>\n          </div>\n          <div className=\"flex justify-between\">\n            <span className=\"text-sm text-muted-foreground\">{t('createdTime')}</span>\n            <span className=\"text-sm font-medium\">\n              {loading ? '...' : formatDate(metadata?.createdAt || null)}\n            </span>\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex gap-3\">\n          <button\n            onClick={handleOpenExternal}\n            className=\"flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors\"\n          >\n            <ExternalLink className=\"w-4 h-4\" />\n            {t('openExternal')}\n          </button>\n          <button\n            onClick={handleOpenDirectory}\n            className=\"flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg border bg-card hover:bg-accent transition-colors\"\n          >\n            <FolderOpen className=\"w-4 h-4\" />\n            {t('openDirectory')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/file-actions.tsx",
    "content": "\"use client\"\n\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { FilePlus, FolderPlus, FolderInput, LoaderCircle } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport * as React from \"react\"\nimport useArticleStore from \"@/stores/article\"\nimport { debounce } from \"lodash-es\"\nimport { open as openDialog } from '@tauri-apps/plugin-dialog'\nimport { readDir, copyFile, mkdir, exists } from '@tauri-apps/plugin-fs'\nimport { join } from '@tauri-apps/api/path'\nimport { getWorkspacePath } from '@/lib/workspace'\nimport { toast } from '@/hooks/use-toast'\n\nexport function FileActions() {\n  const { newFolder, newFile, loadFileTree } = useArticleStore()\n  const t = useTranslations('article.file.toolbar')\n  const [isImporting, setIsImporting] = React.useState(false)\n\n  const debounceNewFile = debounce(newFile, 200)\n  const debounceNewFolder = debounce(newFolder, 200)\n\n  // 递归复制文件夹中的所有 markdown 文件和图片\n  async function copyMarkdownFilesRecursively(\n    sourceDir: string,\n    targetDir: string,\n    relativePath: string = ''\n  ): Promise<number> {\n    let copiedCount = 0\n    \n    try {\n      const entries = await readDir(sourceDir)\n      \n      for (const entry of entries) {\n        // 跳过隐藏文件和文件夹\n        if (entry.name.startsWith('.')) {\n          continue\n        }\n        \n        const sourcePath = await join(sourceDir, entry.name)\n        const newRelativePath = relativePath ? await join(relativePath, entry.name) : entry.name\n        const targetPath = await join(targetDir, newRelativePath)\n        \n        if (entry.isDirectory) {\n          // 递归处理子文件夹\n          const subDirCopied = await copyMarkdownFilesRecursively(\n            sourcePath,\n            targetDir,\n            newRelativePath\n          )\n          copiedCount += subDirCopied\n        } else if (entry.isFile) {\n          // 检查是否是 markdown 文件或图片文件\n          const isMd = entry.name.endsWith('.md')\n          const isImage = /\\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(entry.name)\n          \n          if (isMd || isImage) {\n            // 确保目标文件夹存在\n            const targetDirPath = relativePath ? await join(targetDir, relativePath) : targetDir\n            if (!(await exists(targetDirPath))) {\n              await mkdir(targetDirPath, { recursive: true })\n            }\n            \n            // 复制文件\n            await copyFile(sourcePath, targetPath)\n            copiedCount++\n          }\n        }\n      }\n    } catch (error) {\n      console.error('Error copying files:', error)\n      throw error\n    }\n    \n    return copiedCount\n  }\n\n  async function handleImportMarkdown() {\n    try {\n      setIsImporting(true)\n      \n      // 打开文件夹选择对话框\n      const selectedPath = await openDialog({\n        directory: true,\n        multiple: false,\n        title: t('importMarkdown')\n      })\n      \n      if (!selectedPath) {\n        setIsImporting(false)\n        return\n      }\n      \n      // 获取工作区路径\n      const workspace = await getWorkspacePath()\n      const targetDir = workspace.isCustom ? workspace.path : await join(await import('@tauri-apps/api/path').then(m => m.appDataDir()), 'article')\n      \n      // 递归复制所有 markdown 文件和图片\n      const copiedCount = await copyMarkdownFilesRecursively(selectedPath as string, targetDir)\n      \n      // 刷新文件树\n      await loadFileTree()\n      \n      // 显示成功提示\n      toast({\n        title: t('importSuccess'),\n        description: t('importSuccessDesc', { count: copiedCount })\n      })\n    } catch (error) {\n      console.error('Import markdown error:', error)\n      toast({\n        title: t('importError'),\n        description: String(error),\n        variant: 'destructive'\n      })\n    } finally {\n      setIsImporting(false)\n    }\n  }\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      <TooltipButton \n        icon={<FilePlus className=\"h-4 w-4\" />} \n        tooltipText={t('newArticle')} \n        onClick={debounceNewFile}\n        side=\"bottom\"\n      />\n      <TooltipButton \n        icon={<FolderPlus className=\"h-4 w-4\" />} \n        tooltipText={t('newFolder')} \n        onClick={debounceNewFolder}\n        side=\"bottom\"\n      />\n      <TooltipButton \n        icon={isImporting ? <LoaderCircle className=\"animate-spin h-4 w-4\" /> : <FolderInput className=\"h-4 w-4\" />} \n        tooltipText={isImporting ? t('importing') : t('importMarkdown')} \n        onClick={handleImportMarkdown}\n        disabled={isImporting}\n        side=\"bottom\"\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/file-footer.tsx",
    "content": "'use client'\n\nimport { Button } from \"@/components/ui/button\"\nimport { FolderOpen, FolderSync, SortAsc, SortDesc, ChevronsDownUp, ChevronsUpDown, ArrowDownAZ, Calendar, Clock, ChevronDown, FolderPlus, Cloud } from \"lucide-react\"\nimport useSettingStore from \"@/stores/setting\"\nimport useArticleStore from \"@/stores/article\"\nimport { useSkillsStore } from \"@/stores/skills\"\nimport { useTranslations } from 'next-intl'\nimport { useMemo } from \"react\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuSeparator,\n  DropdownMenuLabel,\n} from \"@/components/ui/dropdown-menu\"\nimport { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from \"@/components/ui/tooltip\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { open as openDialog } from '@tauri-apps/plugin-dialog'\nimport { BottomBarIconButton } from \"@/components/bottom-bar-icon-button\"\n\nexport function FileFooter() {\n  const { workspacePath, workspaceHistory, setWorkspacePath } = useSettingStore()\n  const { refreshSkills } = useSkillsStore()\n  const {\n    clearCollapsibleList,\n    loadFileTree,\n    setActiveFilePath,\n    setCurrentArticle,\n    sortType,\n    setSortType,\n    sortDirection,\n    setSortDirection,\n    toggleAllFolders,\n    collapsibleList,\n    showCloudFiles,\n    setShowCloudFiles\n  } = useArticleStore()\n  const tFile = useTranslations('settings.file')\n  const tToolbar = useTranslations('article.file.toolbar')\n\n  // 获取文件夹名称\n  const getWorkspaceName = (path: string) => {\n    if (!path) return tFile('workspace.defaultPath')\n    return path.split('/').pop() || path.split('\\\\').pop() || path\n  }\n\n  // 当前工作区名称\n  const currentWorkspaceName = useMemo(() => {\n    return getWorkspaceName(workspacePath)\n  }, [workspacePath, tFile])\n\n  // 选择工作区目录\n  async function handleSelectWorkspace() {\n    try {\n      const selected = await openDialog({\n        directory: true,\n        multiple: false,\n        title: tFile('workspace.select')\n      })\n      \n      if (selected) {\n        const path = selected as string\n        await switchWorkspace(path)\n      }\n    } catch (error) {\n      console.error('选择工作区失败:', error)\n    }\n  }\n\n  // 切换工作区\n  async function switchWorkspace(path: string) {\n    if (path === workspacePath) return\n\n    try {\n      await setWorkspacePath(path)\n      await clearCollapsibleList()\n      setActiveFilePath('')\n      setCurrentArticle('')\n      await loadFileTree()\n      await refreshSkills()\n    } catch (error) {\n      console.error('切换工作区失败:', error)\n    }\n  }\n\n  // 重置为默认工作区\n  async function handleResetWorkspace() {\n    try {\n      await setWorkspacePath('')\n      await clearCollapsibleList()\n      setActiveFilePath('')\n      setCurrentArticle('')\n      await loadFileTree()\n      await refreshSkills()\n    } catch (error) {\n      console.error('重置工作区失败:', error)\n    }\n  }\n\n  return (\n    <div className=\"flex h-6 items-center justify-between gap-1 overflow-hidden border-t border-border bg-background px-2 text-xs text-muted-foreground\">\n      {/* 左侧：工作区选择器 */}\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            className=\"flex h-5 flex-1 justify-between border-0 bg-transparent px-1.5 text-xs text-muted-foreground hover:bg-accent focus:ring-0\"\n          >\n            <span className=\"truncate text-xs\">{currentWorkspaceName}</span>\n            <ChevronDown className=\"ml-1 size-3 shrink-0 opacity-50\" />\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent align=\"start\">\n          {/* 选择新工作区 */}\n          <DropdownMenuLabel>{tFile('workspace.actions')}</DropdownMenuLabel>\n          <DropdownMenuItem onClick={handleSelectWorkspace}>\n            <FolderPlus className=\"mr-2 h-4 w-4\" />\n            {tFile('workspace.select')}\n          </DropdownMenuItem>\n          {workspacePath && (\n            <DropdownMenuItem onClick={handleResetWorkspace}>\n              <FolderOpen className=\"mr-2 h-4 w-4\" />\n              {tFile('workspace.defaultPath')}\n            </DropdownMenuItem>\n          )}\n          \n          {/* 历史工作区 */}\n          {workspaceHistory.length > 0 && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuLabel>{tFile('workspace.history')}</DropdownMenuLabel>\n              {workspaceHistory.map((path, index) => (\n                <DropdownMenuItem key={index} onClick={() => switchWorkspace(path)}>\n                  <FolderOpen className=\"mr-2 h-4 w-4\" />\n                  <span className=\"truncate\" title={path}>{getWorkspaceName(path)}</span>\n                </DropdownMenuItem>\n              ))}\n            </>\n          )}\n          \n          {/* 默认工作区 */}\n          {!workspacePath && workspaceHistory.length === 0 && (\n            <>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem disabled>\n                <FolderOpen className=\"mr-2 h-4 w-4\" />\n                {tFile('workspace.defaultPath')}\n              </DropdownMenuItem>\n            </>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <Separator orientation=\"vertical\" />\n\n      {/* 右侧：排序、云端开关、展开、刷新 */}\n      <div className=\"flex items-center gap-1\">\n        {/* 云端文件开关 */}\n        <BottomBarIconButton\n          icon={<Cloud className={`size-3 ${showCloudFiles ? 'text-primary' : 'opacity-40'}`} />}\n          label={showCloudFiles ? tToolbar('hideCloudFiles') : tToolbar('showCloudFiles')}\n          onClick={() => setShowCloudFiles(!showCloudFiles)}\n          active={showCloudFiles}\n        />\n\n        {/* 排序 */}\n        <TooltipProvider>\n          <DropdownMenu>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <DropdownMenuTrigger asChild>\n                  <Button variant=\"ghost\" size=\"icon\" className=\"relative size-5 rounded-sm\">\n                    {sortDirection === 'asc' ? <SortAsc className={`size-3 ${sortType !== 'none' ? 'text-primary' : ''}`} /> : <SortDesc className={`size-3 ${sortType !== 'none' ? 'text-primary' : ''}`} />}\n                  </Button>\n                </DropdownMenuTrigger>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{tToolbar('sort')}</p>\n              </TooltipContent>\n            </Tooltip>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem onClick={() => setSortType('name')} className={sortType === 'name' ? 'bg-accent' : ''}>\n                <ArrowDownAZ className=\"mr-2 h-4 w-4\" />\n                {tToolbar('sortByName')}\n              </DropdownMenuItem>\n              <DropdownMenuItem onClick={() => setSortType('created')} className={sortType === 'created' ? 'bg-accent' : ''}>\n                <Calendar className=\"mr-2 h-4 w-4\" />\n                {tToolbar('sortByCreated')}\n              </DropdownMenuItem>\n              <DropdownMenuItem onClick={() => setSortType('modified')} className={sortType === 'modified' ? 'bg-accent' : ''}>\n                <Clock className=\"mr-2 h-4 w-4\" />\n                {tToolbar('sortByModified')}\n              </DropdownMenuItem>\n              <DropdownMenuItem onClick={() => setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')} className=\"border-t mt-1 pt-1\">\n                {sortDirection === 'asc' ? (\n                  <>\n                    <SortDesc className=\"mr-2 h-4 w-4\" />\n                    {tToolbar('sortDesc')}\n                  </>\n                ) : (\n                  <>\n                    <SortAsc className=\"mr-2 h-4 w-4\" />\n                    {tToolbar('sortAsc')}\n                  </>\n                )}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </TooltipProvider>\n\n        {/* 折叠/展开 */}\n        <BottomBarIconButton \n          icon={collapsibleList.length > 0 ? <ChevronsDownUp className=\"size-3\" /> : <ChevronsUpDown className=\"size-3\" />} \n          label={collapsibleList.length > 0 ? tToolbar('collapseAll') : tToolbar('expandAll')} \n          onClick={toggleAllFolders}\n        />\n\n        {/* 刷新 */}\n        <BottomBarIconButton \n          icon={<FolderSync className=\"size-3\" />} \n          label={tToolbar('refresh')} \n          onClick={loadFileTree}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/file-item.tsx",
    "content": "import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { BaseDirectory, exists, readTextFile, remove, rename, writeTextFile } from \"@tauri-apps/plugin-fs\";\nimport { Copy, Database, File, FileDown, FileUp, FolderOpen, ImageIcon, LoaderCircle, RefreshCwOff, Trash2 } from \"lucide-react\"\nimport { useEffect, useRef, useState, useCallback } from \"react\";\nimport { ask } from '@tauri-apps/plugin-dialog';\nimport { platform } from '@tauri-apps/plugin-os';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { RepoNames } from \"@/lib/sync/github.types\";\nimport { S3Config, WebDAVConfig } from \"@/types/sync\";\nimport { cloneDeep } from \"lodash-es\";\nimport { openPath } from \"@tauri-apps/plugin-opener\";\nimport { computedParentPath, getCurrentFolder } from \"@/lib/path\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { useTranslations } from \"next-intl\";\nimport useClipboardStore from \"@/stores/clipboard\";\nimport { appDataDir, join } from '@tauri-apps/api/path';\nimport { deleteFile } from \"@/lib/sync/github\";\nimport { deleteFile as deleteGiteeFile } from \"@/lib/sync/gitee\";\nimport { deleteFile as deleteGitlabFile } from \"@/lib/sync/gitlab\";\nimport { deleteFile as deleteGiteaFile } from \"@/lib/sync/gitea\";\nimport { s3Delete } from \"@/lib/sync/s3\";\nimport { webdavDelete } from \"@/lib/sync/webdav\";\nimport { generateUniqueFilename } from \"@/lib/default-filename\";\nimport { MobileActionMenu, MobileMenuItem, MobileSeparator } from \"./mobile-action-menu\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport useSettingStore from \"@/stores/setting\";\nimport { VectorKnowledgeMenu } from \"./vector-knowledge-menu\";\nimport { isSkillsFolder } from \"@/lib/skills/utils\";\n\ntype Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\nexport function FileItem({ item, focusSidebar }: { item: DirTree; focusSidebar?: () => void }) {\n  const [isEditing, setIsEditing] = useState(item.isEditing)\n  const [name, setName] = useState(item.name)\n  const [isComposing, setIsComposing] = useState(false) // 追踪输入法合成状态\n  const inputRef = useRef<HTMLInputElement>(null)\n  const { activeFilePath, setActiveFilePath, readArticle, fileTree, setFileTree, loadFileTree, vectorIndexedFiles, checkFileVectorIndexed, cleanTabsByDeletedFile, cleanTabsByDeletedFolder } = useArticleStore()\n  const setArticleState = useArticleStore.setState\n  const { setClipboardItem, clipboardItem, clipboardOperation } = useClipboardStore()\n  const { fileManagerTextSize } = useSettingStore()\n  const t = useTranslations('article.file')\n  const isMobile = useIsMobile()\n\n  // 检查路径是否在 skills 文件夹下\n  const isInSkillsFolder = (itemPath: string): boolean => {\n    const parts = itemPath.split('/')\n    return parts.some(part => isSkillsFolder(part))\n  }\n\n  const path = computedParentPath(item)\n\n  // 向量状态更新回调\n  const handleVectorUpdated = useCallback(() => {\n    checkFileVectorIndexed(path)\n  }, [path, checkFileVectorIndexed])\n\n  // 根据文字大小映射图标大小\n  const getIconSize = (textSize: string) => {\n    const sizeMap = {\n      'xs': 'size-3',\n      'sm': 'size-3.5',\n      'md': 'size-4',\n      'lg': 'size-5',\n      'xl': 'size-6'\n    }\n    return sizeMap[textSize as keyof typeof sizeMap] || 'size-4'\n  }\n\n  const iconSize = getIconSize(fileManagerTextSize)\n\n  // 检查文件是否被剪切\n  const isCut = clipboardOperation === 'cut' && clipboardItem?.path === path\n\n  // 检查文件是否已计算向量（skills 文件夹下的文件不显示）\n  const hasVector = item.isFile && !isInSkillsFolder(path) && vectorIndexedFiles.has(path)\n\n  // 向量计算状态图标\n  const renderVectorIcon = () => {\n    if (isInSkillsFolder(path)) return null\n\n    const status = item.vectorCalcStatus\n\n    if (status === 'calculating') {\n      return <LoaderCircle className={`${iconSize} mr-2 animate-spin`} />\n    } else if (status === 'completed' || hasVector) {\n      return <Database className={`${iconSize} text-muted-foreground mr-2 opacity-60`} />\n    }\n    return null\n  }\n\n  const isRoot = path.split('/').length === 1\n  const folderPath = path.includes('/') ? path.split('/').slice(0, -1).join('/') : ''\n  // 不需要 cloneDeep，因为 getCurrentFolder 只读取数据不修改\n  const currentFolder = getCurrentFolder(folderPath, fileTree)\n\n  // 优化的输入处理，支持输入法\n  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const input = e.target\n    const value = input.value\n    const cursorPosition = input.selectionStart || 0\n    \n    // 如果正在使用输入法合成，不进行空格替换\n    if (isComposing) {\n      setName(value)\n      return\n    }\n    \n    // 检查是否包含空格，只有包含空格时才需要处理光标位置\n    if (value.includes(' ')) {\n      const sanitizedValue = value.replace(/\\s+/g, '_')\n      setName(sanitizedValue)\n      \n      // 保持光标位置\n      requestAnimationFrame(() => {\n        if (input.selectionStart !== null) {\n          input.setSelectionRange(cursorPosition, cursorPosition)\n        }\n      })\n    } else {\n      setName(value)\n    }\n  }, [isComposing])\n\n  // 输入法合成开始\n  const handleCompositionStart = useCallback(() => {\n    setIsComposing(true)\n  }, [])\n\n  // 输入法合成结束，进行空格替换\n  const handleCompositionEnd = useCallback((e: React.CompositionEvent<HTMLInputElement>) => {\n    setIsComposing(false)\n    const input = e.currentTarget\n    const value = input.value\n    const cursorPosition = input.selectionStart || 0\n    \n    // 只有当值包含空格时才需要替换和恢复光标位置\n    if (value.includes(' ')) {\n      const sanitizedValue = value.replace(/\\s+/g, '_')\n      setName(sanitizedValue)\n      \n      // 计算新的光标位置（空格变为下划线，长度不变，所以位置保持不变）\n      requestAnimationFrame(() => {\n        if (input.selectionStart !== null) {\n          input.setSelectionRange(cursorPosition, cursorPosition)\n        }\n      })\n    } else {\n      setName(value)\n    }\n  }, [])\n\n  async function handleSelectFile() {\n    // 让文件管理器获得焦点，以便响应快捷键\n    focusSidebar?.()\n    const currentPath = computedParentPath(item)\n\n    if (item.name.match(/\\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i)) {\n      // 图片文件：设置 activeFilePath，让 EditorLayout 显示图片编辑器\n      setActiveFilePath(currentPath)\n    } else if (item.name.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template)$/i)) {\n      // Markdown/文本文件：设置 activeFilePath\n      setActiveFilePath(currentPath)\n\n      // 检查是否是远程文件\n      // 读取内容的逻辑移到 EditorLayout 中处理，避免重复渲染\n    } else {\n      // 其他文件类型：设置 activeFilePath，让 EditorLayout 显示 UnsupportedFile 组件\n      setActiveFilePath(currentPath)\n    }\n  }\n\n  async function handleDeleteFile() {\n    // 添加确认弹窗\n    const answer = await ask(t('deleteConfirm'), {\n      title: item.name,\n      kind: 'warning',\n    });\n    // 如果用户确认删除，则继续执行\n    if (answer) {\n      try {\n        // 获取工作区路径信息\n        const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n        const workspace = await getWorkspacePath()\n\n        // 使用当前路径，而不是重新计算的路径\n        const currentPath = computedParentPath(item)\n\n        // 根据工作区类型正确删除文件\n        const pathOptions = await getFilePathOptions(currentPath)\n\n        if (workspace.isCustom) {\n          // 自定义工作区\n          await remove(pathOptions.path)\n        } else {\n          // 默认工作区\n          await remove(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        // 更新文件树\n        if (currentFolder) {\n          const index = currentFolder.children?.findIndex(file => file.name === item.name)\n          if (index !== undefined && index !== -1 && currentFolder.children) {\n            const current = currentFolder.children[index]\n            if (current.sha) {\n              // 有云端版本：只标记为非本地文件，保留云端文件\n              current.isLocale = false\n            } else {\n              // 纯本地文件：直接从文件树中移除\n              currentFolder.children.splice(index, 1)\n            }\n          }\n        } else {\n          // 根目录文件：需要克隆 fileTree 来更新\n          const cacheTree = cloneDeep(fileTree)\n          const index = cacheTree.findIndex(file => file.name === item.name)\n          if (index !== undefined && index !== -1) {\n            const current = cacheTree[index]\n            if (current.sha) {\n              // 有云端版本：只标记为非本地文件，保留云端文件\n              current.isLocale = false\n            } else {\n              // 纯本地文件：直接从文件树中移除\n              cacheTree.splice(index, 1)\n            }\n          }\n          setFileTree(cacheTree)\n        }\n\n        // 删除向量数据库中的记录\n        try {\n          const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n          await deleteVectorDocumentsByFilename(path)\n          // 从向量索引映射中移除\n          const newMap = new Map(vectorIndexedFiles)\n          newMap.delete(path)\n          setArticleState({ vectorIndexedFiles: newMap })\n        } catch (error) {\n          console.error(`删除文件 ${item.name} 的向量数据失败:`, error)\n        }\n\n        // 清理已被删除的文件对应的 tabs（包括自动选择其他 tab）\n        await cleanTabsByDeletedFile(currentPath)\n      } catch (error) {\n        console.error('Delete file failed:', error)\n        toast({\n          title: t('context.deleteLocalFile'),\n          description: '删除文件失败: ' + error,\n          variant: 'destructive'\n        })\n      }\n    }\n  }\n\n  async function handleDeleteSyncFile() {\n    const answer = await ask(t('context.deleteSyncFile') + '?', {\n      title: item.name,\n      kind: 'warning',\n    });\n    if (answer) {\n      const currentPath = computedParentPath(item)\n\n      // 设置 loading 状态\n      const cacheTree = cloneDeep(fileTree)\n      const setLoadingStatus = (items: typeof cacheTree): boolean => {\n        for (const entry of items) {\n          const entryPath = computedParentPath(entry)\n          if (entryPath === currentPath && entry.isFile) {\n            entry.loading = true\n            return true\n          }\n          if (entry.children && setLoadingStatus(entry.children)) {\n            return true\n          }\n        }\n        return false\n      }\n      if (setLoadingStatus(cacheTree)) {\n        setFileTree(cacheTree)\n      }\n\n      try {\n        // 获取当前主要备份方式\n        const store = await Store.load('store.json');\n        const backupMethod = await store.get<'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'>('primaryBackupMethod') || 'github';\n\n        let success = false\n        switch (backupMethod) {\n          case 'github': {\n            const result = await deleteFile({ path: currentPath, sha: item.sha as string, repo: RepoNames.sync });\n            success = !!result\n            break;\n          }\n          case 'gitee': {\n            const result = await deleteGiteeFile({ path: currentPath, sha: item.sha as string, repo: RepoNames.sync });\n            success = result !== false\n            break;\n          }\n          case 'gitlab': {\n            const result = await deleteGitlabFile({ path: currentPath, sha: item.sha as string, repo: RepoNames.sync });\n            success = !!result\n            break;\n          }\n          case 'gitea': {\n            const result = await deleteGiteaFile({ path: currentPath, sha: item.sha as string, repo: RepoNames.sync });\n            success = !!result\n            break;\n          }\n          case 's3': {\n            const s3Config = await store.get<S3Config>('s3SyncConfig')\n            if (s3Config) {\n              const result = await s3Delete(s3Config, currentPath)\n              success = result\n            }\n            break;\n          }\n          case 'webdav': {\n            const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n            if (webdavConfig) {\n              const result = await webdavDelete(webdavConfig, currentPath)\n              success = result\n            }\n            break;\n          }\n        }\n\n        if (success) {\n          // 只更新当前文件的状态，不刷新整个文件树\n          const cacheTree = cloneDeep(fileTree)\n\n          // 递归查找并更新/删除文件\n          const updateOrRemoveFile = (items: typeof cacheTree): boolean => {\n            for (let i = 0; i < items.length; i++) {\n              const entry = items[i]\n              const entryPath = computedParentPath(entry)\n              if (entryPath === currentPath && entry.isFile) {\n                if (entry.isLocale) {\n                  // 本地存在：只清除远程 SHA\n                  entry.sha = undefined\n                  entry.loading = undefined\n                } else {\n                  // 本地不存在：从列表中移除\n                  items.splice(i, 1)\n                }\n                return true\n              }\n              if (entry.children && updateOrRemoveFile(entry.children)) {\n                return true\n              }\n            }\n            return false\n          }\n\n          if (updateOrRemoveFile(cacheTree)) {\n            setFileTree(cacheTree)\n          }\n\n          toast({\n            title: t('context.delete'),\n            description: t('context.deleteSyncFileSuccess'),\n          });\n        } else {\n          // 删除失败，清除 loading 状态\n          const cacheTree = cloneDeep(fileTree)\n          const clearLoadingStatus = (items: typeof cacheTree): boolean => {\n            for (const entry of items) {\n              const entryPath = computedParentPath(entry)\n              if (entryPath === currentPath && entry.isFile) {\n                entry.loading = undefined\n                return true\n              }\n              if (entry.children && clearLoadingStatus(entry.children)) {\n                return true\n              }\n            }\n            return false\n          }\n          if (clearLoadingStatus(cacheTree)) {\n            setFileTree(cacheTree)\n          }\n          throw new Error('删除操作返回失败')\n        }\n      } catch (error) {\n        // 删除失败，清除 loading 状态\n        const cacheTree = cloneDeep(fileTree)\n        const clearLoadingStatus = (items: typeof cacheTree): boolean => {\n          for (const entry of items) {\n            const entryPath = computedParentPath(entry)\n            if (entryPath === currentPath && entry.isFile) {\n              entry.loading = undefined\n              return true\n            }\n            if (entry.children && clearLoadingStatus(entry.children)) {\n              return true\n            }\n          }\n          return false\n        }\n        if (clearLoadingStatus(cacheTree)) {\n          setFileTree(cacheTree)\n        }\n        console.error('[handleDeleteSyncFile] 删除远程文件失败:', error);\n        toast({\n          title: t('context.delete'),\n          description: t('context.deleteSyncFileError'),\n          variant: 'destructive',\n        });\n      }\n    }\n  }\n\n  async function handleStartRename() {\n    // 延迟执行，确保上下文菜单完全关闭\n    setTimeout(() => {\n      setIsEditing(true)\n      setTimeout(() => {\n        const input = inputRef.current\n        if (input) {\n          input.focus()\n          // 只选中文件名，不包含扩展名\n          const lastDotIndex = item.name.lastIndexOf('.')\n          if (lastDotIndex > 0) {\n            input.setSelectionRange(0, lastDotIndex)\n          } else {\n            input.select()\n          }\n        }\n      }, 100)\n    }, 300)\n  }\n\n  async function handleRename() {\n    // 获取工作区路径信息\n    const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n    const workspace = await getWorkspacePath()\n    \n    let finalName = name\n    \n    // 如果输入为空字符串，生成默认文件名\n    if (!name || name.trim() === '') {\n      const parentPath = path.includes('/') ? path.split('/').slice(0, -1).join('/') : ''\n      finalName = await generateUniqueFilename(parentPath, 'Untitled')\n      setName(finalName)\n    } else {\n      // 统一处理：将空格替换为下划线，确保本地和远程文件名一致\n      finalName = name.replace(/\\s+/g, '_')\n      setName(finalName)\n    }\n  \n    if (finalName && finalName.trim() !== '' && finalName !== item.name) {\n      // 确保新文件名如果需要.md后缀则添加后缀\n      let displayName = finalName;\n      if (item.name === '' && !displayName.endsWith('.md')) {\n        displayName += '.md';\n      }\n      \n      // 更新缓存树中的名称\n      if (currentFolder && currentFolder.children) {\n        const fileIndex = currentFolder?.children?.findIndex(file => file.name === item.name)\n        if (fileIndex !== undefined && fileIndex !== -1) {\n          currentFolder.children[fileIndex].name = displayName\n          currentFolder.children[fileIndex].isEditing = false\n        }\n      } else {\n        // 根目录文件：需要克隆 fileTree 来更新\n        const cacheTree = cloneDeep(fileTree)\n        const fileIndex = cacheTree.findIndex(file => file.name === item.name)\n        if (fileIndex !== -1 && fileIndex !== undefined) {\n          cacheTree[fileIndex].name = displayName\n          cacheTree[fileIndex].isEditing = false\n        }\n        setFileTree(cacheTree)\n      }\n      \n      // 确定是重命名现有文件还是创建新文件\n      if (item.name !== '') {\n        // 重命名现有文件\n        // 获取源路径和目标路径\n        const oldPathOptions = await getFilePathOptions(path)\n        const newPathRelative = path.split('/').slice(0, -1).join('/') + '/' + displayName\n        const newPathOptions = await getFilePathOptions(newPathRelative)\n        \n        // 根据工作区类型执行重命名操作\n        if (workspace.isCustom) {\n          await rename(oldPathOptions.path, newPathOptions.path)\n        } else {\n          await rename(oldPathOptions.path, newPathOptions.path, { \n            newPathBaseDir: BaseDirectory.AppData, \n            oldPathBaseDir: BaseDirectory.AppData \n          })\n        }\n      } else {\n        // 创建新文件\n        let newFilePath = finalName\n        if (!newFilePath.endsWith('.md')) {\n          newFilePath += '.md'\n        }\n        \n        // 获取新文件的完整路径\n        const parentPath = path.split('/').slice(0, -1).join('/')\n        const fullRelativePath = parentPath ? `${parentPath}/${newFilePath}` : newFilePath\n        const pathOptions = await getFilePathOptions(fullRelativePath)\n        \n        // 检查文件是否已存在\n        let isExists = false\n        if (workspace.isCustom) {\n          isExists = await exists(pathOptions.path)\n        } else {\n          isExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n        \n        if (isExists) {\n          toast({ title: '文件名已存在' })\n          setTimeout(() => inputRef.current?.focus(), 300);\n          return\n        } else {\n          // 创建新文件\n          if (workspace.isCustom) {\n            await writeTextFile(pathOptions.path, '')\n          } else {\n            await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n          }\n        }\n      }\n      \n      // 构建新文件的完整路径用于激活文件\n      let newPath = path.split('/').slice(0, -1).join('/') + '/' + displayName\n      // 判断 newPath 是否以 / 开头\n      if (newPath.startsWith('/')) {\n        newPath = newPath.slice(1)\n      }\n      setActiveFilePath(newPath)\n      // 新建文件后自动选择该文件并读取内容\n      readArticle(newPath, '', true)\n    } else {\n      // 处理取消创建或无变更的情况\n      if (item.name === '') {\n        // 只有当原文件名为空（新建文件）时才删除列表项\n        if (currentFolder && currentFolder.children) {\n          const index = currentFolder?.children?.findIndex(item => item.name === '')\n          if (index !== undefined && index !== -1 && currentFolder?.children) {\n            currentFolder?.children?.splice(index, 1)\n          }\n          setFileTree(fileTree)\n        } else {\n          // 根目录文件：需要克隆 fileTree 来更新\n          const cacheTree = cloneDeep(fileTree)\n          const index = cacheTree.findIndex(item => item.name === '')\n          if (index !== -1) {\n            cacheTree.splice(index, 1)\n          }\n          setFileTree(cacheTree)\n        }\n      } else {\n        // 对于重命名现有文件，如果没有输入新名称，则保持原状态\n        if (currentFolder && currentFolder.children) {\n          const fileIndex = currentFolder?.children?.findIndex(file => file.name === item.name)\n          if (fileIndex !== undefined && fileIndex !== -1) {\n            currentFolder.children[fileIndex].isEditing = false\n          }\n          setFileTree(fileTree)\n        } else {\n          // 根目录文件：需要克隆 fileTree 来更新\n          const cacheTree = cloneDeep(fileTree)\n          const fileIndex = cacheTree.findIndex(file => file.name === item.name)\n          if (fileIndex !== -1 && fileIndex !== undefined) {\n            cacheTree[fileIndex].isEditing = false\n          }\n          setFileTree(cacheTree)\n        }\n      }\n    }\n\n    setIsEditing(false)\n  }\n\n  async function handleShowFileManager() {\n    // 获取工作区路径信息\n    const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n    const workspace = await getWorkspacePath()\n    \n    // 确定文件所在的目录路径\n    const folderPath = item.parent ? computedParentPath(item.parent) : ''\n    \n    // 根据工作区类型确定正确的路径\n    if (workspace.isCustom) {\n      // 自定义工作区 - 直接使用工作区路径\n      const pathOptions = await getFilePathOptions(folderPath)\n      openPath(pathOptions.path)\n    } else {\n      // 默认工作区 - 使用 AppData 目录\n      const appDir = await appDataDir()\n      openPath(await join(appDir, 'article', folderPath))\n    }\n  }\n\n  async function handleDragStart(ev: React.DragEvent<HTMLDivElement>) {\n    ev.dataTransfer.setData('text', path)\n  }\n\n  async function handleCopyFile() {\n    setClipboardItem({\n      path,\n      name: item.name,\n      isDirectory: false,\n      sha: item.sha,\n      isLocale: item.isLocale\n    }, 'copy')\n    toast({ title: t('clipboard.copied') })\n  }\n\n  async function handleCutFile() {\n    setClipboardItem({\n      path,\n      name: item.name,\n      isDirectory: false,\n      sha: item.sha,\n      isLocale: item.isLocale\n    }, 'cut')\n    toast({ title: t('clipboard.cut') })\n  }\n\n  async function handlePasteFile() {\n    if (!clipboardItem) {\n      toast({ title: t('clipboard.empty'), variant: 'destructive' })\n      return\n    }\n\n    try {\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n\n      // 粘贴目标：文件所在的目录（同级粘贴）\n      const targetDir = path.includes('/') ? path.split('/').slice(0, -1).join('/') : ''\n\n      // 检查是否会造成循环嵌套\n      if (clipboardItem.isDirectory) {\n        // 检查是否粘贴到其子文件夹内部（targetDir 以 clipboardItem.path/ 开头）\n        // 注意：允许粘贴到自身内部（targetDir === clipboardItem.path），但需要特殊处理避免循环\n        if (targetDir.startsWith(clipboardItem.path + '/')) {\n          toast({ title: '无法将父文件夹粘贴到其子文件夹内部', variant: 'destructive' })\n          return\n        }\n      }\n\n      if (clipboardItem.isDirectory) {\n        // 粘贴文件夹\n        const { generateCopyFoldername } = await import('@/lib/default-filename')\n        const { mkdir, readDir } = await import('@tauri-apps/plugin-fs')\n\n        const targetName = await generateCopyFoldername(targetDir, clipboardItem.name)\n        const targetPathRelative = targetDir ? `${targetDir}/${targetName}` : targetName\n        const targetPathOptions = await getFilePathOptions(targetPathRelative)\n        const sourcePathOptions = await getFilePathOptions(clipboardItem.path)\n\n        // 检查是否是粘贴到自身内部（需要避免循环引用）\n        const isPasteIntoSelf = targetDir === clipboardItem.path\n\n        // 创建目标文件夹\n        if (workspace.isCustom) {\n          await mkdir(targetPathOptions.path)\n        } else {\n          await mkdir(targetPathOptions.path, { baseDir: targetPathOptions.baseDir })\n        }\n\n        // 递归复制文件夹内容\n        const copyDirRecursively = async (srcRelative: string, destRelative: string) => {\n          const entries = await readDir(\n            srcRelative,\n            workspace.isCustom ? {} : { baseDir: sourcePathOptions.baseDir || BaseDirectory.AppData }\n          )\n\n          for (const entry of entries) {\n            const srcEntryPath = `${srcRelative}/${entry.name}`\n            const destEntryPath = `${destRelative}/${entry.name}`\n\n            if (entry.isDirectory) {\n              // 如果粘贴到自身内部，跳过与目标文件夹同名的子文件夹（避免循环引用）\n              if (isPasteIntoSelf && entry.name === targetName) {\n                continue\n              }\n\n              if (workspace.isCustom) {\n                await mkdir(destEntryPath)\n              } else {\n                await mkdir(destEntryPath, { baseDir: targetPathOptions.baseDir })\n              }\n              await copyDirRecursively(srcEntryPath, destEntryPath)\n            } else {\n              try {\n                let content = ''\n                if (workspace.isCustom) {\n                  content = await readTextFile(srcEntryPath)\n                  await writeTextFile(destEntryPath, content)\n                } else {\n                  content = await readTextFile(srcEntryPath, { baseDir: sourcePathOptions.baseDir || BaseDirectory.AppData })\n                  await writeTextFile(destEntryPath, content, { baseDir: targetPathOptions.baseDir })\n                }\n              } catch (err) {\n                console.error(`Error copying file ${srcEntryPath}:`, err)\n              }\n            }\n          }\n        }\n\n        await copyDirRecursively(sourcePathOptions.path, targetPathOptions.path)\n\n        // 如果是剪切操作，删除原文件夹\n        if (clipboardOperation === 'cut') {\n          if (workspace.isCustom) {\n            await remove(sourcePathOptions.path, { recursive: true })\n          } else {\n            await remove(sourcePathOptions.path, { baseDir: sourcePathOptions.baseDir, recursive: true })\n          }\n          // 清理已被删除的原文件夹对应的 tabs\n          await cleanTabsByDeletedFolder(clipboardItem?.path || '')\n          setClipboardItem(null, 'none')\n        }\n      } else {\n        // 粘贴文件\n        const sourcePathOptions = await getFilePathOptions(clipboardItem.path)\n        const { generateCopyFilename } = await import('@/lib/default-filename')\n        const uniqueFilename = await generateCopyFilename(targetDir, clipboardItem.name)\n        const targetPathRelative = targetDir ? `${targetDir}/${uniqueFilename}` : uniqueFilename\n        const targetPathOptions = await getFilePathOptions(targetPathRelative)\n\n        // Read content from source file\n        let content = ''\n        if (workspace.isCustom) {\n          content = await readTextFile(sourcePathOptions.path)\n          await writeTextFile(targetPathOptions.path, content)\n        } else {\n          content = await readTextFile(sourcePathOptions.path, { baseDir: sourcePathOptions.baseDir })\n          await writeTextFile(targetPathOptions.path, content, { baseDir: targetPathOptions.baseDir })\n        }\n\n        // If cut operation, delete the original file\n        if (clipboardOperation === 'cut') {\n          if (workspace.isCustom) {\n            await remove(sourcePathOptions.path)\n          } else {\n            await remove(sourcePathOptions.path, { baseDir: sourcePathOptions.baseDir })\n          }\n          // 清理已被删除的原文件对应的 tabs\n          await cleanTabsByDeletedFile(clipboardItem?.path || '')\n          // Clear clipboard after cut & paste operation\n          setClipboardItem(null, 'none')\n        }\n      }\n\n      // Refresh file tree\n      loadFileTree()\n      toast({ title: t('clipboard.pasted') })\n    } catch (error) {\n      console.error('Paste operation failed:', error)\n      toast({ title: t('clipboard.pasteFailed'), variant: 'destructive' })\n    }\n  }\n\n  async function handleEditEnd() {\n    if (currentFolder && currentFolder.children) {\n      const index = currentFolder?.children?.findIndex(item => item.name === '')\n      if (index !== undefined && index !== -1 && currentFolder?.children) {\n        currentFolder?.children?.splice(index, 1)\n      }\n      setFileTree(fileTree)\n    } else {\n      // 根目录文件：需要克隆 fileTree 来更新\n      const cacheTree = cloneDeep(fileTree)\n      const index = cacheTree.findIndex(item => item.name === '')\n      if (index !== -1) {\n        cacheTree.splice(index, 1)\n      }\n      setFileTree(cacheTree)\n    }\n    setIsEditing(false)\n  }\n\n  useEffect(() => {\n    if (item.isEditing) {\n      setIsEditing(true)\n      setName(name)\n      setTimeout(() => inputRef.current?.focus(), 300);\n    }\n  }, [item])\n\n  // 监听文件管理器统一快捷键触发的自定义事件\n  useEffect(() => {\n    const handleRenameEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ path: string }>\n      if (customEvent.detail.path === path) {\n        handleStartRename()\n      }\n    }\n\n    const handleDeleteEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ item: { path: string } }>\n      if (customEvent.detail.item.path === path) {\n        handleDeleteFile()\n      }\n    }\n\n    const handlePasteEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ targetPath: string }>\n      // 粘贴到文件所在目录（同级粘贴）\n      if (customEvent.detail.targetPath === path) {\n        handlePasteFile()\n      }\n    }\n\n    window.addEventListener('filemanager-rename', handleRenameEvent)\n    window.addEventListener('filemanager-delete', handleDeleteEvent)\n    window.addEventListener('filemanager-paste', handlePasteEvent)\n\n    return () => {\n      window.removeEventListener('filemanager-rename', handleRenameEvent)\n      window.removeEventListener('filemanager-delete', handleDeleteEvent)\n      window.removeEventListener('filemanager-paste', handlePasteEvent)\n    }\n  }, [path, handleStartRename, handleDeleteFile, handlePasteFile])\n\n  // 获取当前平台（用于显示快捷键）\n  const [currentPlatform, setCurrentPlatform] = useState<Platform>('unknown')\n\n  useEffect(() => {\n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch {\n      setCurrentPlatform('unknown')\n    }\n  }, [])\n\n  // 快捷键显示文本\n  const modKey = currentPlatform === 'macos' ? '⌘' : 'Ctrl'\n  const deleteKey = currentPlatform === 'macos' ? '⌫' : 'Del'\n  const renameKey = currentPlatform === 'macos' ? '↩' : 'F2'\n\n  return (\n    <>\n      <ContextMenu>\n        <ContextMenuTrigger>\n          <div\n            className={`${path === activeFilePath ? 'file-manange-item active' : 'file-manange-item'} ${!isRoot && 'translate-x-5 w-[calc(100%-20px)]!'}`}\n            onClick={handleSelectFile}\n          >\n            {\n              isEditing ? \n              <div className=\"flex gap-1 items-center w-full select-none\">\n                <span className={item.parent ? 'size-0' : `${iconSize} ml-1`} />\n                <File className={iconSize} />\n                <Input\n                  ref={inputRef}\n                  className={`h-5 rounded-sm text-${fileManagerTextSize} px-1 font-normal flex-1 mr-1`}\n                  value={name}\n                  onBlur={handleRename}\n                  onChange={handleInputChange}\n                  onCompositionStart={handleCompositionStart}\n                  onCompositionEnd={handleCompositionEnd}\n                  onKeyDown={(e) => {\n                    // 阻止删除快捷键冒泡到全局快捷键处理器\n                    if (e.key === 'Backspace' || e.key === 'Delete') {\n                      e.stopPropagation()\n                    }\n                    if (e.code === 'Enter' && !e.nativeEvent.isComposing) {\n                      handleRename()\n                    } else if (e.code === 'Escape') {\n                      handleEditEnd()\n                    }\n                  }}\n                />\n              </div> :\n              item.name.match(/\\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i) ?\n              <span\n                draggable\n                onDragStart={handleDragStart}\n                title={item.name}\n                className={`${!item.isLocale || isCut ? 'opacity-50' : ''} flex justify-between flex-1 select-none items-center gap-1 dark:hover:text-white`}>\n                <div className=\"flex flex-1 gap-1 select-none relative items-center\">\n                  <span className={item.parent ? 'size-0' : `${iconSize} ml-1`}></span>\n                  <div className=\"relative flex items-center\">\n                    <ImageIcon className={iconSize} />\n                  </div>\n                  <span className={`text-${fileManagerTextSize} flex-1 line-clamp-1`}>{item.name}</span>\n                  {path === activeFilePath && renderVectorIcon()}\n                </div>\n                {isMobile && (\n                  <MobileActionMenu className=\"ml-1\">\n                    <MobileMenuItem onClick={handleShowFileManager}>\n                      {t('context.viewDirectory')}\n                    </MobileMenuItem>\n                    <MobileSeparator />\n                    <MobileMenuItem disabled={!item.isLocale} onClick={handleCutFile}>\n                      {t('context.cut')}\n                    </MobileMenuItem>\n                    <MobileMenuItem onClick={handleCopyFile}>\n                      {t('context.copy')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!clipboardItem} onClick={handlePasteFile}>\n                      {t('context.paste')}\n                    </MobileMenuItem>\n                    <MobileSeparator />\n                    <MobileMenuItem disabled={!item.isLocale} onClick={handleStartRename}>\n                      {t('context.rename')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!item.sha} className=\"text-red-600\" onClick={handleDeleteSyncFile}>\n                      {t('context.deleteSyncFile')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!item.isLocale || item.name === ''} className=\"text-red-600\" onClick={handleDeleteFile}>\n                      {t('context.deleteLocalFile')}\n                    </MobileMenuItem>\n                  </MobileActionMenu>\n                )}\n              </span> :\n              <span\n                draggable\n                onDragStart={handleDragStart}\n                title={item.name}\n                className={`${!item.isLocale || isCut ? 'opacity-50' : ''} flex justify-between flex-1 select-none items-center gap-1 dark:hover:text-white`}>\n                <div className=\"flex flex-1 gap-1 select-none relative items-center\">\n                  <span className={item.parent ? 'size-0' : `${iconSize} ml-1`}></span>\n                  <div className=\"relative flex items-center\">\n                    { item.loading ? (\n                      <LoaderCircle className={`${iconSize} animate-spin`} />\n                    ) : item.isLocale ? (\n                      item.sha ? <FileUp className={iconSize} /> : <File className={iconSize} />\n                    ) : (\n                      <FileDown className={iconSize} />\n                    )}\n                  </div>\n                  <span className={`text-${fileManagerTextSize} flex-1 line-clamp-1`}>{item.name}</span>\n                  {path === activeFilePath && renderVectorIcon()}\n                </div>\n                {isMobile && (\n                  <MobileActionMenu className=\"ml-1\">\n                    <MobileMenuItem onClick={handleShowFileManager}>\n                      {t('context.viewDirectory')}\n                    </MobileMenuItem>\n                    <MobileSeparator />\n                    <MobileMenuItem disabled={!item.isLocale} onClick={handleCutFile}>\n                      {t('context.cut')}\n                    </MobileMenuItem>\n                    <MobileMenuItem onClick={handleCopyFile}>\n                      {t('context.copy')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!clipboardItem} onClick={handlePasteFile}>\n                      {t('context.paste')}\n                    </MobileMenuItem>\n                    <MobileSeparator />\n                    <MobileMenuItem disabled={!item.isLocale} onClick={handleStartRename}>\n                      {t('context.rename')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!item.sha} className=\"text-red-600\" onClick={handleDeleteSyncFile}>\n                      {t('context.deleteSyncFile')}\n                    </MobileMenuItem>\n                    <MobileMenuItem disabled={!item.isLocale || item.name === ''} className=\"text-red-600\" onClick={handleDeleteFile}>\n                      {t('context.deleteLocalFile')}\n                    </MobileMenuItem>\n                  </MobileActionMenu>\n                )}\n              </span>\n            }\n          </div>\n        </ContextMenuTrigger>\n        <ContextMenuContent>\n          <ContextMenuItem inset onClick={handleShowFileManager} menuType=\"file\">\n            <FolderOpen className=\"mr-2 h-4 w-4\" />\n            {t('context.viewDirectory')}\n          </ContextMenuItem>\n          <ContextMenuSeparator />\n          <VectorKnowledgeMenu\n            item={item}\n            hasVector={hasVector}\n            onVectorUpdated={handleVectorUpdated}\n          />\n          <ContextMenuSeparator />\n          <ContextMenuItem inset disabled={!item.isLocale} onClick={handleCutFile} menuType=\"file\">\n            <File className=\"mr-2 h-4 w-4\" />\n            {t('context.cut')}\n            <ContextMenuShortcut menuType=\"file\">\n              <Kbd>{modKey}X</Kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem inset onClick={handleCopyFile} menuType=\"file\">\n            <Copy className=\"mr-2 h-4 w-4\" />\n            {t('context.copy')}\n            <ContextMenuShortcut menuType=\"file\">\n              <Kbd>{modKey}C</Kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem inset disabled={!clipboardItem} onClick={handlePasteFile} menuType=\"file\">\n            <File className=\"mr-2 h-4 w-4\" />\n            {t('context.paste')}\n            <ContextMenuShortcut menuType=\"file\">\n              <Kbd>{modKey}V</Kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuSeparator />\n          <ContextMenuItem disabled={!item.isLocale} inset onClick={handleStartRename} menuType=\"file\">\n            <File className=\"mr-2 h-4 w-4\" />\n            {t('context.rename')}\n            <ContextMenuShortcut menuType=\"file\">\n              <Kbd>{renameKey}</Kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n          <ContextMenuItem disabled={!item.sha} inset className=\"text-red-900\" onClick={handleDeleteSyncFile} menuType=\"file\">\n            <RefreshCwOff className=\"mr-2 h-4 w-4\" />\n            {t('context.deleteSyncFile')}\n          </ContextMenuItem>\n          <ContextMenuItem disabled={!item.isLocale || item.name === ''} inset className=\"text-red-900\" onClick={handleDeleteFile} menuType=\"file\">\n            <Trash2 className=\"mr-2 h-4 w-4\" />\n            {t('context.deleteLocalFile')}\n            <ContextMenuShortcut menuType=\"file\">\n              <Kbd>{deleteKey}</Kbd>\n            </ContextMenuShortcut>\n          </ContextMenuItem>\n        </ContextMenuContent>\n      </ContextMenu>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/file-manager.tsx",
    "content": "'use client'\nimport React, { useEffect, useState, useMemo } from \"react\"\nimport { Collapsible, CollapsibleContent } from \"@/components/ui/collapsible\"\nimport useArticleStore, { DirTree } from \"@/stores/article\"\nimport { rename, writeTextFile, writeFile } from \"@tauri-apps/plugin-fs\"\nimport { FileItem } from './file-item'\nimport { FolderItem } from \"./folder-item\"\nimport { computedParentPath } from \"@/lib/path\"\nimport { writeDroppedFileToRoot } from \"./root-drop\"\n\n// 递归过滤文件树，移除云端文件（如果 showCloudFiles 为 false）\nfunction filterFileTree(tree: DirTree[], showCloud: boolean): DirTree[] {\n  if (showCloud) return tree\n\n  return tree\n    .filter(item => item.isLocale)\n    .map(item => ({\n      ...item,\n      children: item.children ? filterFileTree(item.children, showCloud) : undefined\n    }))\n}\n\nfunction Tree({ item, focusSidebar }: { item: DirTree; focusSidebar: () => void }) {\n  const { collapsibleList, setCollapsibleList, loadCollapsibleFiles } = useArticleStore()\n  const path = computedParentPath(item)\n\n  function handleCollapse(isOpen: boolean) {\n    setCollapsibleList(path, isOpen)\n    if (isOpen) {\n      loadCollapsibleFiles(path)\n    }\n  }\n\n  return (\n    item.isFile ?\n    <FileItem item={item} focusSidebar={focusSidebar} /> :\n    <li>\n      <Collapsible\n        onOpenChange={handleCollapse}\n        className=\"group/collapsible [&[data-state=open]>button>.file-manange-item>svg:first-child]:rotate-90\"\n        open={collapsibleList.includes(path)}\n      >\n        <FolderItem item={item} focusSidebar={focusSidebar} />\n        <CollapsibleContent className=\"pl-1\">\n          <ul className=\"pl-2\">\n            {item.children?.map((subItem) => (\n              <Tree key={`${subItem.name}-${subItem.parent?.name}-${subItem.sha || ''}-${subItem.isLocale}`} item={subItem} focusSidebar={focusSidebar} />\n            ))}\n          </ul>\n        </CollapsibleContent>\n      </Collapsible>\n    </li>\n  )\n}\n\nexport function FileManager({ focusSidebar }: { focusSidebar: () => void }) {\n  const [isDragging, setIsDragging] = useState(false)\n  const { activeFilePath, fileTree, loadFileTree, setActiveFilePath, addFile, showCloudFiles } = useArticleStore()\n\n  async function handleDrop (e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault()\n    const renamePath = e.dataTransfer?.getData('text')\n    if (renamePath) {\n      const filename = renamePath.slice(renamePath.lastIndexOf('/') + 1)\n      \n      // 获取工作区路径信息\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n      \n      // 获取源路径和目标路径的选项\n      const oldPathOptions = await getFilePathOptions(renamePath)\n      const newPathOptions = await getFilePathOptions(filename) // 直接使用文件名，表示根目录\n      \n      // 根据工作区类型执行重命名操作\n      if (workspace.isCustom) {\n        // 自定义工作区\n        await rename(oldPathOptions.path, newPathOptions.path)\n      } else {\n        // 默认工作区\n        await rename(oldPathOptions.path, newPathOptions.path, { \n          newPathBaseDir: newPathOptions.baseDir,\n          oldPathBaseDir: oldPathOptions.baseDir\n        })\n      }\n      \n      await loadFileTree()\n      if (renamePath === activeFilePath) {\n        setActiveFilePath(filename)\n      }\n    } else {\n      const files = e.dataTransfer.files\n      for (let i = 0; i < files.length; i += 1) {\n        const file = files[i]\n        // 接受 markdown 和图片文件\n        if (file.name.endsWith('.md')) {\n          const text = await file.text()\n          const { getFilePathOptions } = await import('@/lib/workspace')\n          const sanitizedFileName = await writeDroppedFileToRoot({\n            fileName: file.name,\n            getFilePathOptions,\n            writeTextFile,\n          }, {\n            kind: 'text',\n            content: text,\n          })\n\n          addFile({\n            name: sanitizedFileName,\n            isEditing: false,\n            isLocale: true,\n            isDirectory: false,\n            isFile: true,\n            isSymlink: false\n          })\n        } else if (file.name.match(/\\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i)) {\n          // 处理图片文件，同样需要处理文件名以保持一致性\n          const arrayBuffer = await file.arrayBuffer()\n          const uint8Array = new Uint8Array(arrayBuffer)\n          const { getFilePathOptions } = await import('@/lib/workspace')\n          const sanitizedImageFileName = await writeDroppedFileToRoot({\n            fileName: file.name,\n            getFilePathOptions,\n            writeFile,\n          }, {\n            kind: 'binary',\n            content: uint8Array,\n          })\n\n          addFile({\n            name: sanitizedImageFileName,\n            isEditing: false,\n            isLocale: true,\n            isDirectory: false,\n            isFile: true,\n            isSymlink: false\n          })\n        }\n      }\n    }\n    setIsDragging(false)\n  }\n  \n  function handleDragOver(e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault();\n    setIsDragging(true)\n  }\n\n  function handleDragleave(e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault();\n    setIsDragging(false)\n  }\n\n  useEffect(() => {\n    if (fileTree.length === 0) {\n      loadFileTree()\n    }\n  }, [loadFileTree])\n\n  // 根据开关状态过滤文件树 - 使用 useMemo 缓存结果\n  const filteredFileTree = useMemo(\n    () => filterFileTree(fileTree, showCloudFiles),\n    [fileTree, showCloudFiles]\n  )\n\n  return (\n    <div className={`flex-1 overflow-y-auto ${isDragging && 'outline-2 outline-black outline-dotted -outline-offset-4'}`}>\n      <div className=\"flex-1 p-0\">\n        <div className=\"flex-1\">\n          <ul className=\"h-full\">\n            <div\n              className=\"min-h-0.5\"\n              onDrop={(e) => handleDrop(e)}\n              onDragOver={e => handleDragOver(e)}\n              onDragLeave={(e) => handleDragleave(e)}\n            >\n            </div>\n            {filteredFileTree.map((item) => (\n              <Tree key={`${item.name}-${item.parent?.name || ''}-${item.sha || ''}-${item.isLocale}`} item={item} focusSidebar={focusSidebar} />\n            ))}\n            <div\n              className=\"flex-1 min-h-1\"\n              onDrop={(e) => handleDrop(e)}\n              onDragOver={e => handleDragOver(e)}\n              onDragLeave={(e) => handleDragleave(e)}\n            >\n            </div>\n          </ul>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/file-toolbar.tsx",
    "content": "\"use client\"\nimport {\n  FolderGit2, \n  LoaderCircle,\n  BookA,\n} from \"lucide-react\"\nimport * as React from \"react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport useArticleStore from \"@/stores/article\"\nimport { open } from '@tauri-apps/plugin-shell';\nimport useSettingStore from \"@/stores/setting\"\nimport { RepoNames } from \"@/lib/sync/github.types\"\nimport { GitlabInstanceType } from \"@/lib/sync/gitlab.types\"\nimport { GiteaInstanceType } from \"@/lib/sync/gitea.types\"\nimport { useTranslations } from \"next-intl\"\nimport useVectorStore from \"@/stores/vector\"\nimport useUsername from \"@/hooks/use-username\"\n\nexport function FileToolbar() {\n  const { fileTreeLoading } = useArticleStore()\n  const {\n    primaryBackupMethod,\n    githubCustomSyncRepo,\n    giteeCustomSyncRepo,\n    gitlabCustomSyncRepo,\n    giteaCustomSyncRepo,\n    gitlabInstanceType,\n    gitlabCustomUrl,\n    giteaInstanceType,\n    giteaCustomUrl\n  } = useSettingStore()\n  const { processAllDocuments, isProcessing } = useVectorStore()\n  const t = useTranslations('article.file.toolbar')\n\n  const username = useUsername()\n\n  const repoName = React.useMemo(() => {\n    switch (primaryBackupMethod) {\n      case 'github':\n        return githubCustomSyncRepo.trim() || RepoNames.sync\n      case 'gitee':\n        return giteeCustomSyncRepo.trim() || RepoNames.sync\n      case 'gitlab':\n        return gitlabCustomSyncRepo.trim() || RepoNames.sync\n      case 'gitea':\n        return giteaCustomSyncRepo.trim() || RepoNames.sync\n      default:\n        return RepoNames.sync\n    }\n  }, [primaryBackupMethod, githubCustomSyncRepo, giteeCustomSyncRepo, gitlabCustomSyncRepo, giteaCustomSyncRepo])\n\n  async function openRemoteRepo() {\n    if (!username || !primaryBackupMethod) return\n\n    let baseUrl = ''\n    \n    switch (primaryBackupMethod) {\n      case 'github':\n        baseUrl = 'https://github.com'\n        break\n      case 'gitee':\n        baseUrl = 'https://gitee.com'\n        break\n      case 'gitlab':\n        // 处理 Gitlab 自建实例\n        if (gitlabInstanceType === GitlabInstanceType.SELF_HOSTED && gitlabCustomUrl) {\n          baseUrl = gitlabCustomUrl.replace(/\\/$/, '') // 移除末尾斜杠\n        } else if (gitlabInstanceType === GitlabInstanceType.JIHULAB) {\n          baseUrl = 'https://jihulab.com'\n        } else {\n          baseUrl = 'https://gitlab.com'\n        }\n        break\n      case 'gitea':\n        // 处理 Gitea 自建实例\n        if (giteaInstanceType === GiteaInstanceType.SELF_HOSTED && giteaCustomUrl) {\n          baseUrl = giteaCustomUrl.replace(/\\/$/, '') // 移除末尾斜杠\n        } else {\n          baseUrl = 'https://gitea.com'\n        }\n        break\n      default:\n        return\n    }\n\n    open(`${baseUrl}/${username}/${repoName}`)\n  }\n\n\n  return (\n    <div className=\"flex items-center h-12 border-b px-2\">\n      {/* 向量数据库 */}\n      <TooltipButton\n        icon={isProcessing ? <LoaderCircle className=\"animate-spin size-4\" /> : <BookA className=\"text-primary\" />}\n        tooltipText={isProcessing ? t('processingVectors') : t('calculateVectors')}\n        onClick={processAllDocuments}\n        disabled={isProcessing}\n      />\n      {/* 同步 */}\n      {\n        primaryBackupMethod && username ?\n          <TooltipButton\n            icon={fileTreeLoading ? <LoaderCircle className=\"animate-spin size-4\" /> : <FolderGit2 />}\n            tooltipText={fileTreeLoading ? t('loadingSync') : t('accessRepo')}\n            disabled={!username}\n            onClick={openRemoteRepo}\n          />\n          : null\n      }\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/copy-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath } from \"@/lib/path\";\nimport useClipboardStore from \"@/stores/clipboard\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { Copy } from \"lucide-react\"\nimport { Kbd } from \"@/components/ui/kbd\"\n\ninterface CopyFolderProps {\n  item: DirTree;\n  shortcut?: string;\n}\n\nexport function CopyFolder({ item, shortcut }: CopyFolderProps) {\n  const t = useTranslations('article.file');\n  const { setClipboardItem } = useClipboardStore();\n  const path = computedParentPath(item);\n\n  async function handleCopyFolder() {\n    setClipboardItem({\n      path,\n      name: item.name,\n      isDirectory: true,\n      isLocale: item.isLocale\n    }, 'copy');\n    toast({ title: t('clipboard.copied') });\n  }\n\n  return (\n    <ContextMenuItem inset onClick={handleCopyFolder} menuType=\"file\">\n      <Copy className=\"mr-2 h-4 w-4\" />\n      {t('context.copy')}\n      {shortcut && (\n        <ContextMenuShortcut menuType=\"file\">\n          <Kbd>{shortcut}</Kbd>\n        </ContextMenuShortcut>\n      )}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/cut-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath } from \"@/lib/path\";\nimport useClipboardStore from \"@/stores/clipboard\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { Folder } from \"lucide-react\"\nimport { Kbd } from \"@/components/ui/kbd\"\n\ninterface CutFolderProps {\n  item: DirTree;\n  shortcut?: string;\n}\n\nexport function CutFolder({ item, shortcut }: CutFolderProps) {\n  const t = useTranslations('article.file');\n  const { setClipboardItem } = useClipboardStore();\n  const path = computedParentPath(item);\n\n  async function handleCutFolder() {\n    setClipboardItem({\n      path,\n      name: item.name,\n      isDirectory: true,\n      isLocale: item.isLocale\n    }, 'cut');\n    toast({ title: t('clipboard.cut') });\n  }\n\n  return (\n    <ContextMenuItem\n      inset\n      disabled={!item.isLocale}\n      onClick={handleCutFolder}\n      menuType=\"file\"\n    >\n      <Folder className=\"mr-2 h-4 w-4\" />\n      {t('context.cut')}\n      {shortcut && (\n        <ContextMenuShortcut menuType=\"file\">\n          <Kbd>{shortcut}</Kbd>\n        </ContextMenuShortcut>\n      )}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/delete-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath, getCurrentFolder } from \"@/lib/path\";\nimport { remove } from \"@tauri-apps/plugin-fs\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { cloneDeep } from \"lodash-es\";\nimport { ask } from '@tauri-apps/plugin-dialog';\nimport useSettingStore from '@/stores/setting';\nimport { Trash2 } from \"lucide-react\"\nimport { Kbd } from \"@/components/ui/kbd\"\n\ninterface DeleteFolderProps {\n  item: DirTree;\n  shortcut?: string;\n}\n\nexport function DeleteFolder({ item, shortcut }: DeleteFolderProps) {\n  const t = useTranslations('article.file');\n  const {\n    fileTree,\n    setFileTree,\n    cleanTabsByDeletedFolder\n  } = useArticleStore();\n  const { primaryBackupMethod } = useSettingStore();\n\n  const path = computedParentPath(item);\n\n  async function handleDeleteFolder(event: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n    event.stopPropagation();\n    \n    try {\n      // 获取工作区路径信息\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace');\n      const workspace = await getWorkspacePath();\n      \n      // 确认删除操作\n      const confirmed = await ask(t('context.confirmDelete', { name: item.name }), {\n        title: item.name,\n        kind: 'warning',\n      });\n      \n      if (!confirmed) return;\n\n      // 根据工作区类型确定正确的路径\n      const pathOptions = await getFilePathOptions(path);\n      \n      if (workspace.isCustom) {\n        await remove(pathOptions.path, { recursive: true });\n      } else {\n        await remove(pathOptions.path, { baseDir: pathOptions.baseDir, recursive: true });\n      }\n\n      // 清理已被删除的文件夹对应的 tabs（包括自动选择其他 tab）\n      await cleanTabsByDeletedFolder(path)\n\n      // 从文件树中移除该文件夹\n      const cacheTree = cloneDeep(fileTree);\n      const currentFolder = getCurrentFolder(path, cacheTree);\n      const parentFolder = currentFolder?.parent;\n\n      if (parentFolder && parentFolder.children) {\n        const index = parentFolder.children.findIndex(child => child.name === item.name);\n        if (index !== -1) {\n          parentFolder.children.splice(index, 1);\n        }\n      } else {\n        const index = cacheTree.findIndex(child => child.name === item.name);\n        if (index !== -1) {\n          cacheTree.splice(index, 1);\n        }\n      }\n\n      setFileTree(cacheTree);\n\n      // 删除向量数据库中该文件夹下所有文件的记录\n      try {\n        const { getAllMarkdownFiles } = await import('@/lib/files')\n        const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n        const allFiles = await getAllMarkdownFiles()\n\n        // 找出该文件夹下的所有 Markdown 文件\n        const folderPrefix = path.endsWith('/') ? path : path + '/'\n        const filesInFolder = allFiles.filter(file => file.relativePath.startsWith(folderPrefix))\n\n        // 删除这些文件的向量数据\n        for (const file of filesInFolder) {\n          const filename = file.relativePath\n          try {\n            await deleteVectorDocumentsByFilename(filename)\n          } catch (error) {\n            console.error(`删除文件 ${filename} 的向量数据失败:`, error)\n          }\n        }\n      } catch (error) {\n        console.error('删除文件夹向量数据失败:', error)\n      }\n\n      // 如果启用了同步，同步删除操作\n      if (primaryBackupMethod === 'github') {\n        const { deleteFile: deleteGithubFile } = await import('@/lib/sync/github');\n        await deleteGithubFile({ path, sha: item.sha || '', repo: 'sync' as any });\n      } else if (primaryBackupMethod === 'gitee') {\n        const { deleteFile: deleteGiteeFile } = await import('@/lib/sync/gitee');\n        await deleteGiteeFile({ path, sha: item.sha || '', repo: 'sync' as any });\n      } else if (primaryBackupMethod === 'gitlab') {\n        const { deleteFile: deleteGitlabFile } = await import('@/lib/sync/gitlab');\n        await deleteGitlabFile({ path, sha: item.sha, repo: 'sync' as any });\n      }\n\n      toast({ title: t('context.deleteSuccess') });\n    } catch (error) {\n      console.error('Delete folder failed:', error);\n      toast({ \n        title: t('context.deleteFailed'), \n        variant: 'destructive' \n      });\n    }\n  }\n\n  return (\n    <ContextMenuItem\n      inset\n      className=\"text-red-900\"\n      onClick={handleDeleteFolder}\n      menuType=\"file\"\n    >\n      <Trash2 className=\"mr-2 h-4 w-4\" />\n      {t('context.delete')}\n      {shortcut && (\n        <ContextMenuShortcut menuType=\"file\">\n          <Kbd>{shortcut}</Kbd>\n        </ContextMenuShortcut>\n      )}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/duplicate-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport { DirTree } from \"@/stores/article\";\nimport { computedParentPath } from \"@/lib/path\";\nimport { Copy } from \"lucide-react\"\nimport { Kbd } from \"@/components/ui/kbd\"\nimport { BaseDirectory, mkdir, readDir, readTextFile, writeTextFile } from \"@tauri-apps/plugin-fs\";\nimport { toast } from \"@/hooks/use-toast\";\n\ninterface DuplicateFolderProps {\n  item: DirTree;\n  shortcut?: string;\n}\n\nexport function DuplicateFolder({ item, shortcut }: DuplicateFolderProps) {\n  const path = computedParentPath(item);\n\n  async function handleDuplicateFolder() {\n    try {\n      const { generateCopyFoldername } = await import('@/lib/default-filename')\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n\n      // 获取父目录路径\n      const parentPath = path.includes('/') ? path.split('/').slice(0, -1).join('/') : ''\n\n      // 生成唯一的文件夹名称（带 _copy 后缀）\n      const targetName = await generateCopyFoldername(parentPath, item.name)\n      const targetPath = parentPath ? `${parentPath}/${targetName}` : targetName\n\n      // 获取源路径和目标路径的选项\n      const sourcePathOptions = await getFilePathOptions(path)\n      const targetPathOptions = await getFilePathOptions(targetPath)\n\n      // 创建目标文件夹\n      if (workspace.isCustom) {\n        await mkdir(targetPathOptions.path)\n      } else {\n        await mkdir(targetPathOptions.path, { baseDir: targetPathOptions.baseDir })\n      }\n\n      // 递归复制文件夹内容\n      const copyDirRecursively = async (srcRelative: string, destRelative: string) => {\n        const entries = await readDir(\n          srcRelative,\n          workspace.isCustom ? {} : { baseDir: BaseDirectory.AppData }\n        )\n\n        for (const entry of entries) {\n          const srcEntryPath = `${srcRelative}/${entry.name}`\n          const destEntryPath = `${destRelative}/${entry.name}`\n\n          if (entry.isDirectory) {\n            // 创建子目录\n            if (workspace.isCustom) {\n              await mkdir(destEntryPath)\n            } else {\n              await mkdir(destEntryPath, { baseDir: BaseDirectory.AppData })\n            }\n            await copyDirRecursively(srcEntryPath, destEntryPath)\n          } else {\n            // 复制文件\n            try {\n              let content = ''\n              if (workspace.isCustom) {\n                content = await readTextFile(srcEntryPath)\n                await writeTextFile(destEntryPath, content)\n              } else {\n                content = await readTextFile(srcEntryPath, { baseDir: BaseDirectory.AppData })\n                await writeTextFile(destEntryPath, content, { baseDir: BaseDirectory.AppData })\n              }\n            } catch (err) {\n              console.error(`Error copying file ${srcEntryPath}:`, err)\n            }\n          }\n        }\n      }\n\n      await copyDirRecursively(sourcePathOptions.path, targetPathOptions.path)\n\n      // 刷新文件树\n      const useArticleStore = (await import('@/stores/article')).default\n      useArticleStore.getState().loadFileTree()\n\n      toast({ title: `文件夹已复制为 ${targetName}` })\n    } catch (error) {\n      console.error('Duplicate folder failed:', error)\n      toast({ title: '复制文件夹失败', variant: 'destructive' })\n    }\n  }\n\n  return (\n    <ContextMenuItem inset onClick={handleDuplicateFolder} menuType=\"file\">\n      <Copy className=\"mr-2 h-4 w-4\" />\n      创建副本\n      {shortcut && (\n        <ContextMenuShortcut menuType=\"file\">\n          <Kbd>{shortcut}</Kbd>\n        </ContextMenuShortcut>\n      )}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/folder-vector-menu.tsx",
    "content": "import { ContextMenuItem } from \"@/components/ui/enhanced-context-menu\";\nimport { useTranslations } from \"next-intl\";\nimport { Trash2, RefreshCw, Loader2 } from \"lucide-react\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { useState } from \"react\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { computedParentPath } from \"@/lib/path\";\nimport { collectMarkdownFiles } from \"@/lib/files\";\nimport { calculateFolderVectors } from \"@/lib/folder-vector\";\n\ninterface FolderVectorMenuProps {\n  item: DirTree;\n}\n\nexport function FolderVectorMenu({ item }: FolderVectorMenuProps) {\n  const t = useTranslations('article.file');\n  const { loadFileTree, checkFileVectorIndexed, clearFileVector, setVectorCalcStatus } = useArticleStore();\n  const path = computedParentPath(item);\n\n  const [isCalculating, setIsCalculating] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  // 批量计算文件夹中的向量\n  async function handleBatchCalculate() {\n    if (isCalculating) return;\n\n    // 检查是否真的是目录（防止误将文件当作目录处理）\n    if (!item.isDirectory) {\n      toast({\n        title: '不是目录',\n        description: '只能对目录进行批量向量计算',\n        variant: 'destructive'\n      });\n      return;\n    }\n\n    setIsCalculating(true);\n    setVectorCalcStatus(path, 'calculating');\n\n    try {\n      const markdownFiles = await collectMarkdownFiles(path);\n\n      if (markdownFiles.length === 0) {\n        toast({\n          title: t('context.noMarkdownFiles'),\n          variant: 'destructive'\n        });\n        setIsCalculating(false);\n        setVectorCalcStatus(path, 'idle');\n        return;\n      }\n\n      const result = await calculateFolderVectors({\n        folderPath: path,\n        mode: 'missing',\n        checkFileVectorIndexed,\n        setVectorCalcStatus,\n      });\n\n      if (!result.embeddingModelAvailable) {\n        toast({\n          title: '向量处理',\n          description: '未配置嵌入模型或模型不可用，请在AI设置中配置嵌入模型',\n          variant: 'destructive'\n        });\n        setVectorCalcStatus(path, 'idle');\n        return;\n      }\n\n      const successCount = result.success + result.skipped;\n      const failedCount = result.failed;\n\n      if (failedCount === 0) {\n        toast({\n          title: t('context.batchCalcSuccess', { count: successCount }),\n        });\n      } else {\n        toast({\n          title: t('context.batchCalcPartial', { success: successCount, failed: failedCount }),\n          variant: failedCount === result.total ? 'destructive' : 'default'\n        });\n      }\n\n      // 刷新向量索引状态 - 检查所有文件的向量状态\n      for (const file of markdownFiles) {\n        await checkFileVectorIndexed(file.path);\n      }\n\n      // 设置文件夹为完成状态\n      setVectorCalcStatus(path, 'completed');\n      loadFileTree();\n    } catch (error) {\n      console.error('批量计算向量失败:', error);\n      toast({\n        title: t('context.batchCalcFailed'),\n        variant: 'destructive'\n      });\n      setVectorCalcStatus(path, 'idle');\n    } finally {\n      setIsCalculating(false);\n    }\n  }\n\n  // 批量删除文件夹中的向量\n  async function handleBatchDelete() {\n    if (isDeleting) return;\n\n    try {\n      const markdownFiles = await collectMarkdownFiles(path);\n\n      if (markdownFiles.length === 0) {\n        toast({\n          title: t('context.noMarkdownFiles'),\n          variant: 'destructive'\n        });\n        return;\n      }\n\n      const { ask } = await import('@tauri-apps/plugin-dialog');\n      const confirmed = await ask(\n        t('context.confirmDeleteVectors', { count: markdownFiles.length }),\n        {\n          title: t('context.deleteVectors'),\n          kind: 'warning',\n        }\n      );\n\n      if (!confirmed) return;\n\n      setIsDeleting(true);\n\n      let successCount = 0;\n      let failedCount = 0;\n\n      for (const file of markdownFiles) {\n        try {\n          await clearFileVector(file.path);\n          successCount++;\n        } catch (error) {\n          console.error(`删除文件 ${file.name} 向量失败:`, error);\n          failedCount++;\n        }\n      }\n\n      if (failedCount === 0) {\n        toast({\n          title: t('context.batchDeleteSuccess', { count: successCount }),\n        });\n      } else {\n        toast({\n          title: t('context.batchDeletePartial', { success: successCount, failed: failedCount }),\n          variant: failedCount === markdownFiles.length ? 'destructive' : 'default'\n        });\n      }\n\n      loadFileTree();\n    } catch (error) {\n      console.error('批量删除向量失败:', error);\n      toast({\n        title: t('context.batchDeleteFailed'),\n        variant: 'destructive'\n      });\n    } finally {\n      setIsDeleting(false);\n    }\n  }\n\n  return (\n    <>\n      <ContextMenuItem\n        inset\n        disabled={isCalculating}\n        onClick={handleBatchCalculate}\n        menuType=\"file\"\n      >\n        {isCalculating ? (\n          <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n        ) : (\n          <RefreshCw className=\"mr-2 h-4 w-4\" />\n        )}\n        {t('context.calculateVectors')}\n      </ContextMenuItem>\n\n      <ContextMenuItem\n        inset\n        disabled={isDeleting}\n        className=\"text-red-600\"\n        onClick={handleBatchDelete}\n        menuType=\"file\"\n      >\n        {isDeleting ? (\n          <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n        ) : (\n          <Trash2 className=\"mr-2 h-4 w-4\" />\n        )}\n        {t('context.deleteVectors')}\n      </ContextMenuItem>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/index.tsx",
    "content": "import { ContextMenu, ContextMenuContent, ContextMenuSeparator, ContextMenuTrigger, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent } from \"@/components/ui/enhanced-context-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { BaseDirectory, exists, mkdir, rename } from \"@tauri-apps/plugin-fs\";\nimport { ChevronRight, Folder, FolderDot, FolderDown, FolderOpen, FolderOpenDot, FolderUp, Loader2, LoaderCircle, Database, Sparkles } from \"lucide-react\"\nimport { useEffect, useRef, useState, useCallback } from \"react\";\nimport { CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { cloneDeep } from \"lodash-es\";\nimport { computedParentPath, getCurrentFolder } from \"@/lib/path\";\nimport useSettingStore from '@/stores/setting'\nimport { isSkillsFolder } from \"@/lib/skills/utils\"\nimport SyncFolder from './sync-folder'\nimport { NewFile } from './new-file'\nimport { NewFolder } from './new-folder'\nimport { ViewDirectory } from './view-directory'\nimport { CutFolder } from './cut-folder'\nimport { CopyFolder } from './copy-folder'\nimport { DuplicateFolder } from './duplicate-folder'\nimport { PasteInFolder } from './paste-in-folder'\nimport { RenameFolder } from './rename-folder'\nimport { DeleteFolder } from './delete-folder'\nimport useClipboardStore from \"@/stores/clipboard\"\nimport { MobileActionMenu, MobileMenuItem, MobileSeparator } from \"../mobile-action-menu\"\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { useTranslations } from \"next-intl\"\nimport { FolderVectorMenu } from './folder-vector-menu'\nimport { pasteIntoFolder } from './paste-into-folder'\nimport emitter from '@/lib/emitter'\nimport { LinkedFolder } from '@/lib/files'\n\nexport function FolderItem({ item, focusSidebar }: { item: DirTree; focusSidebar?: () => void }) {\n  const [isEditing, setIsEditing] = useState(item.isEditing)\n  const [name, setName] = useState(item.name)\n  const [isComposing, setIsComposing] = useState(false)\n  const [isDragging, setIsDragging] = useState(false)\n  const inputRef = useRef<HTMLInputElement>(null)\n\n  const { assetsPath, fileManagerTextSize } = useSettingStore()\n  const isMobile = useIsMobile()\n  const t = useTranslations('article.file')\n\n  // 检查路径是否在 skills 文件夹下\n  const isInSkillsFolder = (itemPath: string): boolean => {\n    const parts = itemPath.split('/')\n    return parts.some(part => isSkillsFolder(part))\n  }\n\n  // 根据文字大小映射图标大小\n  const getIconSize = (textSize: string) => {\n    const sizeMap = {\n      'xs': 'size-3',\n      'sm': 'size-3.5', \n      'md': 'size-4',\n      'lg': 'size-5',\n      'xl': 'size-6'\n    }\n    return sizeMap[textSize as keyof typeof sizeMap] || 'size-4'\n  }\n\n  const iconSize = getIconSize(fileManagerTextSize)\n\n  const {\n    activeFilePath,\n    loadFileTree,\n    setActiveFilePath,\n    collapsibleList,\n    setCollapsibleList,\n    loadCollapsibleFiles,\n    fileTree,\n    setFileTree,\n    vectorIndexedFiles\n  } = useArticleStore()\n  const { setClipboardItem, clipboardItem, clipboardOperation } = useClipboardStore()\n\n  const path = computedParentPath(item)\n  const cacheTree = cloneDeep(fileTree)\n  const currentFolder = getCurrentFolder(path, cacheTree)\n  const parentFolder = currentFolder?.parent\n\n  // 检查文件夹是否被剪切\n  const isCut = clipboardOperation === 'cut' && clipboardItem?.path === path\n\n  // 计算文件夹的向量状态\n  const folderVectorStatus = useCallback(() => {\n    let totalCount = 0\n    let indexedCount = 0\n\n    function countFiles(node: DirTree) {\n      if (!node.children) {\n        // 如果是文件（没有 children）\n        if (node.name.endsWith('.md')) {\n          totalCount++\n          if (vectorIndexedFiles.has(computedParentPath(node))) {\n            indexedCount++\n          }\n        }\n        return\n      }\n\n      // 递归计算子节点\n      node.children.forEach(child => countFiles(child))\n    }\n\n    countFiles(item)\n\n    return {\n      totalCount,\n      indexedCount,\n      hasVector: totalCount > 0 && indexedCount > 0,\n      isComplete: totalCount > 0 && indexedCount === totalCount\n    }\n  }, [item, vectorIndexedFiles])\n\n  // 渲染文件夹的向量状态图标\n  const renderFolderVectorIcon = () => {\n    if (isInSkillsFolder(path)) return null\n\n    const status = item.vectorCalcStatus\n    const vectorStatus = folderVectorStatus()\n\n    if (status === 'calculating') {\n      return (\n        <div className=\"flex items-center mr-2\">\n          <LoaderCircle className={`${iconSize} animate-spin`} />\n        </div>\n      )\n    } else if (status === 'completed' || vectorStatus.hasVector) {\n      return (\n        <div className=\"flex items-center mr-2\">\n          <span className={`text-xs text-muted-foreground ${vectorStatus.isComplete ? 'opacity-100' : 'opacity-60'}`}>\n            {vectorStatus.indexedCount}/{vectorStatus.totalCount}\n          </span>\n          <Database className={`${iconSize} text-muted-foreground ml-1 ${vectorStatus.isComplete ? 'opacity-100' : 'opacity-60'}`} />\n        </div>\n      )\n    }\n    return null\n  }\n\n  // 移动端处理函数\n  function handleNewFile() {\n    // 创建临时文件节点，并将其设为编辑状态\n    const cacheTree = cloneDeep(fileTree);\n    const currentFolder = getCurrentFolder(path, cacheTree);\n    \n    // 如果文件夹中已经有一个空名称的文件，不再创建新的\n    if (currentFolder?.children?.find(item => item.name === '' && item.isFile)) {\n      return;\n    }\n    \n    // 确保文件夹是展开状态\n    if (!collapsibleList.includes(path)) {\n      setCollapsibleList(path, true);\n    }\n    \n    if (currentFolder) {\n      const newFile: DirTree = {\n        name: '',\n        isFile: true,\n        isSymlink: false,\n        parent: currentFolder,\n        isEditing: true,\n        isDirectory: false,\n        isLocale: true,\n        sha: '',\n        children: []\n      };\n      currentFolder.children?.unshift(newFile);\n      setFileTree(cacheTree);\n    }\n  }\n\n  function handleNewFolder() {\n    // 创建临时文件夹节点\n    const cacheTree = cloneDeep(fileTree);\n    const currentFolder = getCurrentFolder(path, cacheTree);\n    \n    // 如果文件夹中已经有一个空名称的文件夹，不再创建新的\n    if (currentFolder?.children?.find(item => item.name === '' && item.isDirectory)) {\n      return;\n    }\n    \n    // 确保文件夹是展开状态\n    if (!collapsibleList.includes(path)) {\n      setCollapsibleList(path, true);\n    }\n    \n    if (currentFolder) {\n      const newFolder: DirTree = {\n        name: '',\n        isFile: false,\n        isSymlink: false,\n        parent: currentFolder,\n        isEditing: true,\n        isDirectory: true,\n        isLocale: true,\n        sha: '',\n        children: []\n      };\n      currentFolder.children?.unshift(newFolder);\n      setFileTree(cacheTree);\n    }\n  }\n\n  function handleStartRename() {\n    // 延迟执行，确保上下文菜单完全关闭\n    setTimeout(() => {\n      setIsEditing(true)\n      setTimeout(() => {\n        const input = inputRef.current\n        if (input) {\n          input.focus()\n          // 只选中文件名，不包含扩展名\n          const lastDotIndex = item.name.lastIndexOf('.')\n          if (lastDotIndex > 0) {\n            input.setSelectionRange(0, lastDotIndex)\n          } else {\n            input.select()\n          }\n        }\n      }, 100)\n    }, 300)\n  }\n\n  // 粘贴到文件夹\n  async function handlePasteInFolder() {\n    await pasteIntoFolder({\n      clipboardItem,\n      clipboardOperation,\n      folderPath: path,\n      emptyToastTitle: t('clipboard.empty'),\n      pastedToastTitle: t('clipboard.pasted'),\n      pasteFailedToastTitle: t('clipboard.pasteFailed'),\n      loadFileTree,\n      setClipboardItem,\n    })\n  }\n\n  // 删除文件夹\n  async function handleDeleteFolder() {\n    try {\n      // 获取工作区路径信息\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n      const { ask } = await import('@tauri-apps/plugin-dialog')\n      const { remove } = await import('@tauri-apps/plugin-fs')\n\n      // 确认删除操作\n      const confirmed = await ask(t('context.confirmDelete', { name: item.name }), {\n        title: item.name,\n        kind: 'warning',\n      })\n\n      if (!confirmed) return\n\n      // 根据工作区类型确定正确的路径\n      const pathOptions = await getFilePathOptions(path)\n\n      if (workspace.isCustom) {\n        await remove(pathOptions.path, { recursive: true })\n      } else {\n        await remove(pathOptions.path, { baseDir: pathOptions.baseDir, recursive: true })\n      }\n\n      // 如果删除的文件夹包含当前活动文件，清除活动文件路径\n      if (activeFilePath && activeFilePath.startsWith(path)) {\n        setActiveFilePath('')\n      }\n\n      // 从文件树中移除该文件夹\n      const cacheTree = cloneDeep(fileTree)\n      const parentFolder = currentFolder?.parent\n\n      if (parentFolder && parentFolder.children) {\n        const index = parentFolder.children.findIndex(child => child.name === item.name)\n        if (index !== -1) {\n          parentFolder.children.splice(index, 1)\n        }\n      } else {\n        const index = cacheTree.findIndex(child => child.name === item.name)\n        if (index !== -1) {\n          cacheTree.splice(index, 1)\n        }\n      }\n\n      setFileTree(cacheTree)\n\n      // 删除向量数据库中该文件夹下所有文件的记录\n      try {\n        const { getAllMarkdownFiles } = await import('@/lib/files')\n        const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n        const allFiles = await getAllMarkdownFiles()\n\n        // 找出该文件夹下的所有 Markdown 文件\n        const folderPrefix = path.endsWith('/') ? path : path + '/'\n        const filesInFolder = allFiles.filter(file => file.relativePath.startsWith(folderPrefix))\n\n        // 删除这些文件的向量数据\n        for (const file of filesInFolder) {\n          const filename = file.relativePath\n          try {\n            await deleteVectorDocumentsByFilename(filename)\n          } catch (error) {\n            console.error(`删除文件 ${filename} 的向量数据失败:`, error)\n          }\n        }\n      } catch (error) {\n        console.error('删除文件夹向量数据失败:', error)\n      }\n\n      toast({ title: t('context.deleteSuccess') })\n    } catch (error) {\n      console.error('Delete folder failed:', error)\n      toast({\n        title: t('context.deleteFailed'),\n        variant: 'destructive'\n      })\n    }\n  }\n\n  // 优化的输入处理，支持输入法\n  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const input = e.target\n    const value = input.value\n    const cursorPosition = input.selectionStart || 0\n    \n    // 如果正在使用输入法合成，不进行空格替换\n    if (isComposing) {\n      setName(value)\n      return\n    }\n    \n    // 检查是否包含空格，只有包含空格时才需要处理光标位置\n    if (value.includes(' ')) {\n      const sanitizedValue = value.replace(/\\s+/g, '_')\n      setName(sanitizedValue)\n      \n      // 保持光标位置\n      requestAnimationFrame(() => {\n        if (input.selectionStart !== null) {\n          input.setSelectionRange(cursorPosition, cursorPosition)\n        }\n      })\n    } else {\n      setName(value)\n    }\n  }, [isComposing])\n\n  // 输入法合成开始\n  const handleCompositionStart = useCallback(() => {\n    setIsComposing(true)\n  }, [])\n\n  // 输入法合成结束，进行空格替换\n  const handleCompositionEnd = useCallback((e: React.CompositionEvent<HTMLInputElement>) => {\n    setIsComposing(false)\n    const input = e.currentTarget\n    const value = input.value\n    const cursorPosition = input.selectionStart || 0\n    \n    // 只有当值包含空格时才需要替换和恢复光标位置\n    if (value.includes(' ')) {\n      const sanitizedValue = value.replace(/\\s+/g, '_')\n      setName(sanitizedValue)\n      \n      // 计算新的光标位置（空格变为下划线，长度不变，所以位置保持不变）\n      requestAnimationFrame(() => {\n        if (input.selectionStart !== null) {\n          input.setSelectionRange(cursorPosition, cursorPosition)\n        }\n      })\n    } else {\n      setName(value)\n    }\n  }, [])\n\n  // 创建或修改文件夹名称\n  async function handleRename() {\n    // 统一处理：将空格替换为下划线，确保本地和远程文件名一致\n    const sanitizedName = name.replace(/\\s+/g, '_')\n    setName(sanitizedName)\n\n    // 获取工作区路径信息\n    const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n    const workspace = await getWorkspacePath()\n\n    // 修改文件夹名称\n    if (sanitizedName && sanitizedName !== item.name && item.name !== '') {\n      // 更新缓存树中的名称\n      if (parentFolder && parentFolder.children) {\n        const folderIndex = parentFolder?.children?.findIndex(folder => folder.name === item.name)\n        if (folderIndex !== undefined && folderIndex !== -1) {\n          parentFolder.children[folderIndex].name = sanitizedName\n          parentFolder.children[folderIndex].isEditing = false\n        }\n      } else {\n        const folderIndex = cacheTree.findIndex(folder => folder.name === item.name)\n        cacheTree[folderIndex].name = sanitizedName\n        cacheTree[folderIndex].isEditing = false\n      }\n      \n      // 获取源路径和目标路径\n      const oldPathOptions = await getFilePathOptions(path)\n      const newPathOptions = await getFilePathOptions(`${path.split('/').slice(0, -1).join('/')}/${sanitizedName}`)\n      \n      // 根据工作区类型执行重命名操作\n      if (workspace.isCustom) {\n        await rename(oldPathOptions.path, newPathOptions.path)\n      } else {\n        await rename(oldPathOptions.path, newPathOptions.path, { \n          newPathBaseDir: BaseDirectory.AppData, \n          oldPathBaseDir: BaseDirectory.AppData \n        })\n      }\n    } else {\n      // 已有文件夹但名称未改变，直接取消编辑\n      if (item.name !== '' && sanitizedName === item.name) {\n        setIsEditing(false)\n        return\n      }\n\n      // 新建文件夹\n      if (sanitizedName !== '') {\n        // 检查文件夹是否已存在\n        const newFolderPath = `${path}/${sanitizedName}`\n        const pathOptions = await getFilePathOptions(newFolderPath)\n        \n        let isExists = false\n        if (workspace.isCustom) {\n          isExists = await exists(pathOptions.path)\n        } else {\n          isExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n        \n        if (isExists) {\n          toast({ title: '文件夹名已存在' })\n          return\n        } else {\n          // 创建新文件夹\n          if (workspace.isCustom) {\n            await mkdir(pathOptions.path)\n          } else {\n            await mkdir(pathOptions.path, { baseDir: pathOptions.baseDir })\n          }\n          \n          // 更新缓存树\n          if (parentFolder && parentFolder.children) {\n            const index = parentFolder.children?.findIndex(item => item.name === '')\n            parentFolder.children[index].name = sanitizedName\n            parentFolder.children[index].isEditing = false\n          } else {\n            const index = cacheTree?.findIndex(item => item.name === '')\n            cacheTree[index].name = sanitizedName\n            cacheTree[index].isEditing = false\n          }\n        }\n      } else {\n        // 处理空名称情况（取消新建）\n        if (currentFolder?.parent) {\n          const index = currentFolder?.parent?.children?.findIndex(item => item.name === '')\n          if (index !== undefined && index !== -1 && currentFolder?.parent?.children) {\n            currentFolder.parent?.children?.splice(index, 1)\n          }\n        } else {\n          const index = cacheTree.findIndex(item => item.name === '')\n          if (index !== -1) {\n            cacheTree.splice(index, 1)\n          }\n        }\n      }\n    } \n    setIsEditing(false)\n    setFileTree(cacheTree)\n  }\n\n\n\n  async function handleDrop(e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault()\n    const renamePath = e.dataTransfer?.getData('text')\n    if (renamePath) {\n      const filename = renamePath.slice(renamePath.lastIndexOf('/') + 1)\n      \n      // 获取工作区路径信息\n      const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n      \n      // 获取源路径和目标路径的选项\n      const oldPathOptions = await getFilePathOptions(renamePath)\n      const newPathOptions = await getFilePathOptions(`${path}/${filename}`)\n      \n      // 根据工作区类型执行重命名操作\n      if (workspace.isCustom) {\n        // 自定义工作区\n        await rename(oldPathOptions.path, newPathOptions.path)\n      } else {\n        // 默认工作区\n        await rename(oldPathOptions.path, newPathOptions.path, { \n          newPathBaseDir: BaseDirectory.AppData, \n          oldPathBaseDir: BaseDirectory.AppData \n        })\n      }\n      \n      // 刷新文件树\n      loadFileTree()\n      \n      // 更新活动文件路径和折叠状态\n      if (renamePath === activeFilePath && !collapsibleList.includes(item.name)) {\n        setCollapsibleList(item.name, true)\n        setActiveFilePath(`${path}/${filename}`)\n      }\n    }\n    setIsDragging(false)\n  }\n\n  function handleDragOver(e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault();\n    setIsDragging(true)\n  }\n\n  function handleDragleave(e: React.DragEvent<HTMLDivElement>) {\n    e.preventDefault();\n    setIsDragging(false)\n  }\n\n  async function handleSelectFolder() {\n    // 检查是否真的是目录（防止误将文件当作目录处理）\n    if (!item.isDirectory) {\n      return\n    }\n\n    // 让文件管理器获得焦点，以便响应快捷键\n    focusSidebar?.()\n    // 设置选中状态\n    await setActiveFilePath(path)\n\n    // 自动展开文件夹（如果未展开）\n    if (!collapsibleList.includes(path)) {\n      await setCollapsibleList(path, true)\n    }\n\n    // 加载文件夹内容\n    await loadCollapsibleFiles(path)\n\n    // 触发文件夹选择事件\n    const folderName = path.split('/').pop() || path\n    let fullPath: string\n    const { getWorkspacePath } = await import('@/lib/workspace')\n    const workspace = await getWorkspacePath()\n    if (workspace.isCustom) {\n      const pathParts = path.split('/')\n      fullPath = workspace.path + '/' + pathParts.join('/')\n    } else {\n      fullPath = path\n    }\n\n    // 计算文件夹中的文件数量\n    const { collectMarkdownFiles } = await import('@/lib/files')\n    const files = await collectMarkdownFiles(path)\n\n    // 获取向量索引状态\n    const indexedCount = files.filter(f =>\n      vectorIndexedFiles.has(f.path)\n    ).length\n\n    // 只有在有索引文件时才触发关联事件\n    if (indexedCount > 0) {\n      // 触发事件\n      emitter.emit('folderSelected', {\n        name: folderName,\n        path: fullPath,\n        relativePath: path,\n        fileCount: files.length,\n        indexedCount: indexedCount\n      } as LinkedFolder)\n    }\n  }\n\n\n\n  function handleEditEnd() {\n    if (currentFolder?.parent) {\n      const index = currentFolder?.parent?.children?.findIndex(item => item.name === '')\n      if (index !== undefined && index !== -1 && currentFolder?.parent?.children) {\n        currentFolder.parent?.children?.splice(index, 1)\n      }\n    } else {\n      const index = cacheTree.findIndex(item => item.name === '')\n      if (index !== -1) {\n        cacheTree.splice(index, 1)\n      }\n    }\n    setFileTree(cacheTree)\n    setIsEditing(false)\n  }\n\n  useEffect(() => {\n    if (item.isEditing) {\n      setIsEditing(true)\n      setName(name)\n      setTimeout(() => inputRef.current?.focus(), 300);\n    }\n  }, [item])\n\n  // 监听文件管理器统一快捷键触发的自定义事件\n  useEffect(() => {\n    const handleRenameEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ path: string }>\n      if (customEvent.detail.path === path) {\n        handleStartRename()\n      }\n    }\n\n    const handleDeleteEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ item: { path: string } }>\n      if (customEvent.detail.item.path === path) {\n        handleDeleteFolder()\n      }\n    }\n\n    const handlePasteEvent = (e: Event) => {\n      const customEvent = e as CustomEvent<{ targetPath: string }>\n      // 粘贴到当前文件夹\n      if (customEvent.detail.targetPath === path) {\n        handlePasteInFolder()\n      }\n    }\n\n    window.addEventListener('filemanager-rename', handleRenameEvent)\n    window.addEventListener('filemanager-delete', handleDeleteEvent)\n    window.addEventListener('filemanager-paste', handlePasteEvent)\n\n    return () => {\n      window.removeEventListener('filemanager-rename', handleRenameEvent)\n      window.removeEventListener('filemanager-delete', handleDeleteEvent)\n      window.removeEventListener('filemanager-paste', handlePasteEvent)\n    }\n  }, [path, handleStartRename, handleDeleteFolder, handlePasteInFolder])\n\n  // 获取当前平台（用于显示快捷键）\n  const [currentPlatform, setCurrentPlatform] = useState<'macos' | 'windows' | 'linux' | 'unknown'>('unknown')\n\n  useEffect(() => {\n    const detectPlatform = async () => {\n      try {\n        const { platform } = await import('@tauri-apps/plugin-os')\n        const p = platform()\n        if (p === 'macos') {\n          setCurrentPlatform('macos')\n        } else if (p === 'windows') {\n          setCurrentPlatform('windows')\n        } else if (p === 'linux') {\n          setCurrentPlatform('linux')\n        }\n      } catch {\n        setCurrentPlatform('unknown')\n      }\n    }\n    detectPlatform()\n  }, [])\n\n  // 快捷键显示文本\n  const modKey = currentPlatform === 'macos' ? '⌘' : 'Ctrl'\n  const deleteKey = currentPlatform === 'macos' ? '⌫' : 'Del'\n  const renameKey = currentPlatform === 'macos' ? '↩' : 'F2'\n\n  return (\n    <CollapsibleTrigger className=\"w-full select-none\">\n      <ContextMenu>\n        <ContextMenuTrigger asChild>\n          <div\n            className={`${isDragging ? 'file-on-drop' : ''} ${path === activeFilePath ? 'active' : ''} group file-manange-item flex select-none`}\n            onClick={() => handleSelectFolder()}\n            onContextMenu={(e) => {\n              // 右键打开菜单时阻止冒泡，防止触发折叠/展开\n              e.stopPropagation();\n            }}\n          >\n            <ChevronRight\n              className=\"transition-transform size-4 ml-1 bg-sidebar group-hover:bg-transparent\"\n              onClick={async (e) => {\n                // 点击折叠箭头时只触发展开/折叠，阻止冒泡避免触发 handleSelectFolder\n                e.stopPropagation();\n                e.preventDefault();\n                // 切换折叠状态\n                const isExpanded = collapsibleList.includes(path)\n                await setCollapsibleList(path, !isExpanded)\n                // 如果是展开操作，加载文件夹内容\n                if (!isExpanded) {\n                  await loadCollapsibleFiles(path)\n                }\n              }}\n            />\n            {\n              isEditing ?\n                <>\n                  {\n                    item.isLocale ?\n                      <Folder className={iconSize} /> :\n                      <FolderDown className={iconSize} />\n                  }\n                  <Input\n                    ref={inputRef}\n                    className={`h-5 rounded-sm text-${fileManagerTextSize} px-1 font-normal flex-1 mr-1`}\n                    value={name}\n                    onBlur={handleRename}\n                    onChange={handleInputChange}\n                    onCompositionStart={handleCompositionStart}\n                    onCompositionEnd={handleCompositionEnd}\n                    onKeyDown={(e) => {\n                      // 阻止删除快捷键冒泡到全局快捷键处理器\n                      if (e.key === 'Backspace' || e.key === 'Delete') {\n                        e.stopPropagation()\n                      }\n                      if (e.code === 'Enter' && !e.nativeEvent.isComposing) {\n                        handleRename()\n                      } else if (e.code === 'Escape') {\n                        handleEditEnd()\n                      }\n                    }}\n                  />\n                </> :\n                <div\n                  onDrop={(e) => handleDrop(e)}\n                  onDragOver={e => handleDragOver(e)}\n                  onDragLeave={(e) => handleDragleave(e)}\n                  className={`${!item.isLocale || isCut ? 'opacity-50' : ''} flex gap-1 items-center flex-1 select-none`}\n                >\n                  <div className=\"flex flex-1 gap-1 select-none relative items-center\">\n                    {item.loading ? (\n                      <Loader2 className={`${iconSize} animate-spin text-primary`} />\n                    ) : isSkillsFolder(item.name) ? (\n                      <Sparkles className={`${iconSize} text-primary`} />\n                    ) : collapsibleList.includes(path) ? (\n                      assetsPath === item.name ? <FolderOpenDot className={iconSize} /> : (!item.isLocale ? <FolderDown className={iconSize} /> : (item.sha ? <FolderUp className={iconSize} /> : <FolderOpen className={iconSize} />))\n                    ) : (\n                      assetsPath === item.name ? <FolderDot className={iconSize} /> : (!item.isLocale ? <FolderDown className={iconSize} /> : (item.sha ? <FolderUp className={iconSize} /> : <Folder className={iconSize} />))\n                    )}\n                    <span className={`text-${fileManagerTextSize} line-clamp-1 ${item.loading ? 'text-muted-foreground' : ''}`}>{item.name}</span>\n                  </div>\n                  {/* 向量状态指示器 - 放在最右侧，skills 文件夹及其子内容不显示 */}\n                  {renderFolderVectorIcon()}\n                  {isMobile && (\n                    <MobileActionMenu className=\"ml-1\">\n                      <MobileMenuItem onClick={handleNewFile} disabled={!!item.sha && !item.isLocale}>\n                        {t('context.newFile')}\n                      </MobileMenuItem>\n                      <MobileMenuItem onClick={handleNewFolder} disabled={!!item.sha && !item.isLocale}>\n                        {t('context.newFolder')}\n                      </MobileMenuItem>\n                      <MobileMenuItem onClick={() => {}}>\n                        {t('context.viewDirectory')}\n                      </MobileMenuItem>\n                      <MobileSeparator />\n                      <MobileMenuItem disabled>\n                        {t('context.cut')}\n                      </MobileMenuItem>\n                      <MobileMenuItem disabled>\n                        {t('context.copy')}\n                      </MobileMenuItem>\n                      <MobileMenuItem disabled>\n                        {t('context.paste')}\n                      </MobileMenuItem>\n                      <MobileSeparator />\n                      <MobileMenuItem disabled>\n                        同步\n                      </MobileMenuItem>\n                      <MobileSeparator />\n                      <MobileMenuItem onClick={handleStartRename} disabled={!!item.sha && !item.isLocale}>\n                        {t('context.rename')}\n                      </MobileMenuItem>\n                      <MobileMenuItem disabled className=\"text-red-600\">\n                        {t('context.delete')}\n                      </MobileMenuItem>\n                    </MobileActionMenu>\n                  )}\n                </div>\n            }\n          </div>\n        </ContextMenuTrigger>\n        <ContextMenuContent>\n          <NewFile item={item} />\n          <NewFolder item={item} />\n          <ViewDirectory item={item} />\n          <ContextMenuSeparator />\n          {/* skills 文件夹及其子内容不显示知识库选项 */}\n          {!isInSkillsFolder(path) && (\n            <>\n              <ContextMenuSub>\n                <ContextMenuSubTrigger>\n                  <Database className=\"mr-2 h-4 w-4\" />\n                  {t('context.knowledgeBase')}\n                </ContextMenuSubTrigger>\n                <ContextMenuSubContent>\n                  <FolderVectorMenu item={item} />\n                </ContextMenuSubContent>\n              </ContextMenuSub>\n              <ContextMenuSeparator />\n            </>\n          )}\n          <CutFolder item={item} shortcut={`${modKey}X`} />\n          <CopyFolder item={item} shortcut={`${modKey}C`} />\n          <DuplicateFolder item={item} />\n          <PasteInFolder item={item} shortcut={`${modKey}V`} />\n          <ContextMenuSeparator />\n          <SyncFolder item={item} />\n          <ContextMenuSeparator />\n          <RenameFolder item={item} onStartRename={handleStartRename} shortcut={renameKey} />\n          <DeleteFolder item={item} shortcut={deleteKey} />\n        </ContextMenuContent>\n      </ContextMenu>\n    </CollapsibleTrigger>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/new-file.tsx",
    "content": "import { ContextMenuItem } from \"@/components/ui/enhanced-context-menu\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { cloneDeep } from \"lodash-es\";\nimport { computedParentPath, getCurrentFolder } from \"@/lib/path\";\nimport { FilePlus } from \"lucide-react\"\n\ninterface NewFileProps {\n  item: DirTree;\n}\n\nexport function NewFile({ item }: NewFileProps) {\n  const t = useTranslations('article.file');\n  const { \n    fileTree,\n    setFileTree,\n    collapsibleList,\n    setCollapsibleList\n  } = useArticleStore();\n\n  const path = computedParentPath(item);\n\n  function newFileHandler(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n    e.stopPropagation();\n    \n    // 创建临时文件节点，并将其设为编辑状态，与 newFile 保持一致\n    const cacheTree = cloneDeep(fileTree);\n    const currentFolder = getCurrentFolder(path, cacheTree);\n    \n    // 如果文件夹中已经有一个空名称的文件，不再创建新的\n    if (currentFolder?.children?.find(item => item.name === '' && item.isFile)) {\n      return;\n    }\n    \n    // 确保文件夹是展开状态\n    if (!collapsibleList.includes(path)) {\n      setCollapsibleList(path, true);\n    }\n    \n    if (currentFolder) {\n      const newFile: DirTree = {\n        name: '',\n        isFile: true,\n        isSymlink: false,\n        parent: currentFolder,\n        isEditing: true,\n        isDirectory: false,\n        isLocale: true,\n        sha: '',\n        children: []\n      };\n      currentFolder.children?.unshift(newFile);\n      setFileTree(cacheTree);\n    }\n  }\n\n  return (\n    <ContextMenuItem\n      inset\n      disabled={!!item.sha && !item.isLocale}\n      onClick={newFileHandler}\n      menuType=\"file\"\n    >\n      <FilePlus className=\"mr-2 h-4 w-4\" />\n      {t('context.newFile')}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/new-folder.tsx",
    "content": "import { ContextMenuItem } from \"@/components/ui/enhanced-context-menu\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath } from \"@/lib/path\";\nimport { FolderPlus } from \"lucide-react\"\n\ninterface NewFolderProps {\n  item: DirTree;\n}\n\nexport function NewFolder({ item }: NewFolderProps) {\n  const t = useTranslations('article.file');\n  const { \n    collapsibleList,\n    setCollapsibleList,\n    newFolderInFolder\n  } = useArticleStore();\n\n  const path = computedParentPath(item);\n\n  function newFolderHandler(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {\n    e.stopPropagation();\n    // 如果当前文件夹未展开，则先展开\n    if (!collapsibleList.includes(path)) {\n      setCollapsibleList(path, true);\n    }\n    newFolderInFolder(path);\n  }\n\n  return (\n    <ContextMenuItem\n      inset\n      disabled={!!item.sha && !item.isLocale}\n      onClick={newFolderHandler}\n      menuType=\"file\"\n    >\n      <FolderPlus className=\"mr-2 h-4 w-4\" />\n      {t('context.newFolder')}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/paste-in-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath } from \"@/lib/path\";\nimport useClipboardStore from \"@/stores/clipboard\";\nimport { FileSymlink } from \"lucide-react\"\nimport { Kbd } from \"@/components/ui/kbd\"\nimport { pasteIntoFolder } from \"./paste-into-folder\";\n\ninterface PasteInFolderProps {\n  item: DirTree;\n  shortcut?: string;\n}\n\nexport function PasteInFolder({ item, shortcut }: PasteInFolderProps) {\n  const t = useTranslations('article.file');\n  const { clipboardItem, clipboardOperation, setClipboardItem } = useClipboardStore();\n  const { loadFileTree } = useArticleStore();\n  const path = computedParentPath(item);\n\n  async function handlePasteInFolder() {\n    await pasteIntoFolder({\n      clipboardItem,\n      clipboardOperation,\n      folderPath: path,\n      emptyToastTitle: t('clipboard.empty'),\n      pastedToastTitle: t('clipboard.pasted'),\n      pasteFailedToastTitle: t('clipboard.pasteFailed'),\n      loadFileTree,\n      setClipboardItem,\n    })\n  }\n\n  return (\n    <ContextMenuItem\n      inset\n      disabled={!clipboardItem}\n      onClick={handlePasteInFolder}\n      menuType=\"file\"\n    >\n      <FileSymlink className=\"mr-2 h-4 w-4\" />\n      {t('context.paste')}\n      {shortcut && (\n        <ContextMenuShortcut menuType=\"file\">\n          <Kbd>{shortcut}</Kbd>\n        </ContextMenuShortcut>\n      )}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/paste-into-folder.js",
    "content": "import { BaseDirectory, mkdir, readDir, readTextFile, remove, writeTextFile } from \"@tauri-apps/plugin-fs\"\n\nimport { toast } from \"@/hooks/use-toast\"\nimport { generateCopyFilename, generateCopyFoldername } from \"@/lib/default-filename\"\nimport { getFilePathOptions, getWorkspacePath } from \"@/lib/workspace\"\n\nimport { getPasteTargetDirectory } from \"./paste-target\"\n\nexport async function pasteIntoFolder({\n  clipboardItem,\n  clipboardOperation,\n  folderPath,\n  emptyToastTitle,\n  pastedToastTitle,\n  pasteFailedToastTitle,\n  loadFileTree,\n  setClipboardItem,\n}) {\n  if (!clipboardItem) {\n    toast({ title: emptyToastTitle, variant: 'destructive' })\n    return false\n  }\n\n  try {\n    const workspace = await getWorkspacePath()\n\n    const targetDir = getPasteTargetDirectory(folderPath)\n    const targetName = clipboardItem.isDirectory\n      ? await generateCopyFoldername(targetDir, clipboardItem.name)\n      : await generateCopyFilename(targetDir, clipboardItem.name)\n\n    const targetPathRelative = targetDir ? `${targetDir}/${targetName}` : targetName\n    const targetPathOptions = await getFilePathOptions(targetPathRelative)\n    const sourcePathOptions = await getFilePathOptions(clipboardItem.path)\n\n    if (clipboardItem.isDirectory) {\n      if (workspace.isCustom) {\n        await mkdir(targetPathOptions.path)\n      } else {\n        await mkdir(targetPathOptions.path, { baseDir: targetPathOptions.baseDir })\n      }\n\n      const copyDirRecursively = async (srcRelative, destRelative) => {\n        const entries = await readDir(\n          srcRelative,\n          workspace.isCustom ? {} : { baseDir: sourcePathOptions.baseDir || BaseDirectory.AppData }\n        )\n\n        for (const entry of entries) {\n          const srcEntryPath = `${srcRelative}/${entry.name}`\n          const destEntryPath = `${destRelative}/${entry.name}`\n\n          if (entry.isDirectory) {\n            if (workspace.isCustom) {\n              await mkdir(destEntryPath)\n            } else {\n              await mkdir(destEntryPath, { baseDir: targetPathOptions.baseDir })\n            }\n            await copyDirRecursively(srcEntryPath, destEntryPath)\n          } else {\n            try {\n              if (workspace.isCustom) {\n                const content = await readTextFile(srcEntryPath)\n                await writeTextFile(destEntryPath, content)\n              } else {\n                const content = await readTextFile(srcEntryPath, { baseDir: sourcePathOptions.baseDir || BaseDirectory.AppData })\n                await writeTextFile(destEntryPath, content, { baseDir: targetPathOptions.baseDir })\n              }\n            } catch (error) {\n              console.error(`Error copying file ${srcEntryPath}:`, error)\n            }\n          }\n        }\n      }\n\n      await copyDirRecursively(sourcePathOptions.path, targetPathOptions.path)\n    } else if (workspace.isCustom) {\n      const content = await readTextFile(sourcePathOptions.path)\n      await writeTextFile(targetPathOptions.path, content)\n    } else {\n      const content = await readTextFile(sourcePathOptions.path, { baseDir: sourcePathOptions.baseDir })\n      await writeTextFile(targetPathOptions.path, content, { baseDir: targetPathOptions.baseDir })\n    }\n\n    if (clipboardOperation === 'cut') {\n      if (workspace.isCustom) {\n        await remove(sourcePathOptions.path, { recursive: true })\n      } else {\n        await remove(sourcePathOptions.path, { baseDir: sourcePathOptions.baseDir, recursive: true })\n      }\n      setClipboardItem(null, 'none')\n    }\n\n    loadFileTree()\n    toast({ title: pastedToastTitle })\n    return true\n  } catch (error) {\n    console.error('Paste operation failed:', error)\n    toast({ title: pasteFailedToastTitle, variant: 'destructive' })\n    return false\n  }\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/paste-target.js",
    "content": "export function getPasteTargetDirectory(folderPath) {\n  return folderPath\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/paste-target.spec.mjs",
    "content": "import test from 'node:test'\nimport assert from 'node:assert/strict'\n\nimport { getPasteTargetDirectory } from './paste-target.js'\n\ntest('uses the current folder path as the paste target for folder items', () => {\n  assert.equal(getPasteTargetDirectory('projects/docs'), 'projects/docs')\n})\n\ntest('keeps a root folder path as the paste target for folder items', () => {\n  assert.equal(getPasteTargetDirectory('inbox'), 'inbox')\n})\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/rename-folder.tsx",
    "content": "import { ContextMenuItem, ContextMenuShortcut } from \"@/components/ui/enhanced-context-menu\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { useTranslations } from \"next-intl\";\nimport { FolderInput } from \"lucide-react\";\nimport { platform } from \"@tauri-apps/plugin-os\";\nimport { useEffect, useState } from \"react\";\n\ninterface RenameFolderProps {\n  item: { name: string };\n  onStartRename: () => void;\n  shortcut?: string;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport function RenameFolder({ item, onStartRename, shortcut }: RenameFolderProps) {\n  const t = useTranslations('article.file');\n  const [renameKey, setRenameKey] = useState('F2');\n\n  useEffect(() => {\n    // 如果从外部传入了快捷键，使用外部传入的\n    if (shortcut) {\n      setRenameKey(shortcut);\n      return;\n    }\n    try {\n      const p = platform();\n      setRenameKey(p === 'macos' ? 'Enter' : 'F2');\n    } catch {\n      setRenameKey('F2');\n    }\n  }, [shortcut]);\n\n  function handleStartRename() {\n    // 不再更新文件树，只调用父组件的重命名处理函数\n    // 父组件会通过本地状态管理编辑状态\n    onStartRename();\n  }\n\n  return (\n    <ContextMenuItem inset onClick={handleStartRename} menuType=\"file\">\n      <FolderInput className=\"mr-2 h-4 w-4\" />\n      {t('context.rename')}\n      <ContextMenuShortcut menuType=\"file\">\n        <Kbd>{renameKey}</Kbd>\n      </ContextMenuShortcut>\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/folder-item/sync-folder.tsx",
    "content": "import { ContextMenuItem } from \"@/components/ui/enhanced-context-menu\";\nimport { RefreshCw } from \"lucide-react\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { useTranslations } from \"next-intl\";\nimport { useState } from \"react\";\nimport useArticleStore, { DirTree } from \"@/stores/article\";\nimport { syncFolderByItem, showFolderSyncToast } from \"@/lib/sync/folder-sync-helper\";\n\nexport default function SyncFolder({ item }: { item: DirTree }) {\n  const t = useTranslations('article.file')\n  const [isSyncing, setIsSyncing] = useState(false)\n\n  const { loadFileTree } = useArticleStore()\n\n  // 同步文件夹下的所有 Markdown 文件\n  async function handleSyncFolder() {\n    if (isSyncing) return\n\n    // 检查是否真的是目录（防止误将文件当作目录处理）\n    if (!item.isDirectory) {\n      toast({\n        title: '不是目录',\n        description: '只能同步目录',\n        variant: 'destructive'\n      });\n      return;\n    }\n\n    setIsSyncing(true);\n    toast({ title: t('context.syncFolderProgress') });\n\n    try {\n      const result = await syncFolderByItem(item)\n      showFolderSyncToast(result)\n    } catch (error) {\n      toast({\n        title: t('context.syncFolderError'),\n        description: String(error),\n        variant: 'destructive'\n      })\n    }\n\n    // 刷新文件树以更新同步状态\n    loadFileTree();\n    setIsSyncing(false);\n  }\n\n  return <ContextMenuItem inset disabled={isSyncing || !item.isLocale} onClick={handleSyncFolder} menuType=\"file\">\n    {isSyncing ? <RefreshCw className=\"mr-2 h-4 w-4 animate-spin\" /> : <RefreshCw className=\"mr-2 h-4 w-4\" />}\n    {t('context.syncFolder')}\n  </ContextMenuItem>\n}"
  },
  {
    "path": "src/app/core/main/file/folder-item/view-directory.tsx",
    "content": "import { ContextMenuItem } from \"@/components/ui/enhanced-context-menu\";\nimport { DirTree } from \"@/stores/article\";\nimport { useTranslations } from \"next-intl\";\nimport { computedParentPath } from \"@/lib/path\";\nimport { appDataDir } from '@tauri-apps/api/path';\nimport { openPath } from \"@tauri-apps/plugin-opener\";\nimport { FolderOpen } from \"lucide-react\"\n\ninterface ViewDirectoryProps {\n  item: DirTree;\n}\n\nexport function ViewDirectory({ item }: ViewDirectoryProps) {\n  const t = useTranslations('article.file');\n  const path = computedParentPath(item);\n\n  async function handleShowFileManager() {\n    // 获取工作区路径信息\n    const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace');\n    const workspace = await getWorkspacePath();\n    \n    // 根据工作区类型确定正确的路径\n    if (workspace.isCustom) {\n      // 自定义工作区 - 直接使用工作区路径\n      const pathOptions = await getFilePathOptions(path);\n      openPath(pathOptions.path);\n    } else {\n      // 默认工作区 - 使用 AppData 目录\n      const appDir = await appDataDir();\n      openPath(`${appDir}/article/${path}`);\n    }\n  }\n\n  return (\n    <ContextMenuItem inset onClick={handleShowFileManager} menuType=\"file\">\n      <FolderOpen className=\"mr-2 h-4 w-4\" />\n      {t('context.viewDirectory')}\n    </ContextMenuItem>\n  );\n}\n"
  },
  {
    "path": "src/app/core/main/file/index.tsx",
    "content": "'use client'\n\nimport React, { useEffect, useState, useCallback, useRef } from \"react\"\nimport { FileManager } from \"./file-manager\"\nimport { FileFooter } from \"./file-footer\"\nimport useArticleStore from \"@/stores/article\"\nimport useClipboardStore from \"@/stores/clipboard\"\nimport { isMobileDevice } from \"@/lib/check\"\nimport { platform } from \"@tauri-apps/plugin-os\"\n\ntype Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\n/**\n * 统一的文件管理器快捷键处理\n * 只有当文件管理器区域获得焦点时才响应快捷键\n */\nfunction useFileManagerShortcuts() {\n  const { activeFilePath, fileTree } = useArticleStore()\n  const { setClipboardItem } = useClipboardStore()\n  const [currentPlatform, setCurrentPlatform] = useState<Platform>('unknown')\n  const [isFocused, setIsFocused] = useState(false)\n  const sidebarRef = useRef<HTMLDivElement>(null)\n\n  // 检测当前平台\n  useEffect(() => {\n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch {\n      setCurrentPlatform('unknown')\n    }\n  }, [])\n\n  // 检查是否按下了正确的修饰键\n  const isModKey = useCallback((e: KeyboardEvent): boolean => {\n    if (currentPlatform === 'macos') {\n      return e.metaKey && !e.ctrlKey\n    } else {\n      return e.ctrlKey && !e.metaKey\n    }\n  }, [currentPlatform])\n\n  const isEditableTarget = useCallback((target: EventTarget | null): boolean => {\n    if (!(target instanceof HTMLElement)) {\n      return false\n    }\n\n    if (target.isContentEditable) {\n      return true\n    }\n\n    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {\n      return true\n    }\n\n    return !!target.closest('input, textarea, select, [contenteditable=\"true\"], [role=\"textbox\"]')\n  }, [])\n\n  // 获取当前激活的 item（文件或文件夹）\n  const getActiveItem = useCallback((): { path: string; isDirectory: boolean; isLocale: boolean; name: string; sha?: string } | null => {\n    if (!activeFilePath) return null\n\n    // 递归查找文件树中匹配的项\n    function findInTree(tree: typeof fileTree, targetPath: string): ReturnType<typeof getActiveItem> {\n      for (const item of tree) {\n        const itemPath = item.parent ? `${item.parent.name}/${item.name}` : item.name\n\n        if (itemPath === targetPath || (item.parent && targetPath === `${item.parent.name}/${item.name}`)) {\n          return {\n            path: activeFilePath,\n            isDirectory: item.isDirectory,\n            isLocale: item.isLocale,\n            name: item.name,\n            sha: item.sha\n          }\n        }\n\n        if (item.children) {\n          const found = findInTree(item.children, targetPath)\n          if (found) return found\n        }\n      }\n      return null\n    }\n\n    return findInTree(fileTree, activeFilePath)\n  }, [activeFilePath, fileTree])\n\n  // 处理快捷键\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    // 移动端不处理快捷键\n    if (isMobileDevice()) {\n      return\n    }\n\n    if (isEditableTarget(e.target)) {\n      return\n    }\n\n    // 只有文件管理器有焦点时才处理\n    if (!isFocused) {\n      return\n    }\n\n    const activeItem = getActiveItem()\n    if (!activeItem || !activeItem.isLocale) {\n      return\n    }\n\n    const modPressed = isModKey(e)\n\n    // 复制: Cmd+C / Ctrl+C\n    if (modPressed && e.key === 'c') {\n      e.preventDefault()\n      e.stopPropagation()\n      setClipboardItem({\n        path: activeItem.path,\n        name: activeItem.name,\n        isDirectory: activeItem.isDirectory,\n        sha: activeItem.sha,\n        isLocale: activeItem.isLocale\n      }, 'copy')\n      return\n    }\n\n    // 剪切: Cmd+X / Ctrl+X\n    if (modPressed && e.key === 'x') {\n      e.preventDefault()\n      e.stopPropagation()\n      setClipboardItem({\n        path: activeItem.path,\n        name: activeItem.name,\n        isDirectory: activeItem.isDirectory,\n        sha: activeItem.sha,\n        isLocale: activeItem.isLocale\n      }, 'cut')\n      return\n    }\n\n    // 粘贴: Cmd+V / Ctrl+V\n    if (modPressed && e.key === 'v') {\n      e.preventDefault()\n      e.stopPropagation()\n      // 触发粘贴操作（通过事件或直接调用）\n      const event = new CustomEvent('filemanager-paste', { detail: { targetPath: activeItem.path } })\n      window.dispatchEvent(event)\n      return\n    }\n\n    // 删除: macOS 使用 Backspace，Windows/Linux 使用 Delete\n    const isDeleteKey = currentPlatform === 'macos'\n      ? e.key === 'Backspace'\n      : e.key === 'Delete'\n\n    if (isDeleteKey) {\n      e.preventDefault()\n      e.stopPropagation()\n      const event = new CustomEvent('filemanager-delete', { detail: { item: activeItem } })\n      window.dispatchEvent(event)\n      return\n    }\n\n    // 重命名: macOS 使用 Enter 键，Windows/Linux 使用 F2 键\n    const isRenameKey = currentPlatform === 'macos'\n      ? e.key === 'Enter'\n      : e.key === 'F2'\n\n    if (isRenameKey) {\n      e.preventDefault()\n      e.stopPropagation()\n      const event = new CustomEvent('filemanager-rename', { detail: { path: activeItem.path } })\n      window.dispatchEvent(event)\n      return\n    }\n  }, [isFocused, getActiveItem, isModKey, currentPlatform, setClipboardItem, isEditableTarget])\n\n  // 注册全局快捷键\n  useEffect(() => {\n    if (isMobileDevice() || currentPlatform === 'unknown') {\n      return\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown)\n    }\n  }, [handleKeyDown, currentPlatform])\n\n  // 焦点处理\n  const handleFocusIn = useCallback((e: FocusEvent) => {\n    // 检查焦点是否在文件管理器区域内\n    if (sidebarRef.current && sidebarRef.current.contains(e.target as Node)) {\n      setIsFocused(true)\n    }\n  }, [])\n\n  const handleFocusOut = useCallback((e: FocusEvent) => {\n    // 检查焦点是否移到了 sidebar 外部\n    // relatedTarget 是即将获得焦点的元素\n    const newFocusedElement = e.relatedTarget as Node\n\n    if (sidebarRef.current && newFocusedElement) {\n      // 如果新焦点元素不在 sidebar 内，才设置 isFocused = false\n      if (!sidebarRef.current.contains(newFocusedElement)) {\n        setIsFocused(false)\n      }\n    } else if (!newFocusedElement) {\n      // 如果 relatedTarget 为 null（焦点移到了文档外），设置 isFocused = false\n      setIsFocused(false)\n    }\n    // 否则，焦点还在 sidebar 内，保持 isFocused = true\n  }, [])\n\n  useEffect(() => {\n    if (sidebarRef.current) {\n      sidebarRef.current.addEventListener('focusin', handleFocusIn)\n      sidebarRef.current.addEventListener('focusout', handleFocusOut)\n\n      return () => {\n        sidebarRef.current?.removeEventListener('focusin', handleFocusIn)\n        sidebarRef.current?.removeEventListener('focusout', handleFocusOut)\n      }\n    }\n  }, [handleFocusIn, handleFocusOut])\n\n  // 主动设置焦点到文件管理器\n  const focusSidebar = useCallback(() => {\n    setIsFocused(true)\n    // 使用 requestAnimationFrame 确保 DOM 更新后再设置焦点\n    requestAnimationFrame(() => {\n      sidebarRef.current?.focus()\n    })\n  }, [])\n\n  return { sidebarRef, isFocused, focusSidebar }\n}\n\nexport function FileSidebar() {\n  const { initCollapsibleList, initSortSettings, initShowCloudFiles } = useArticleStore()\n  const { sidebarRef, focusSidebar } = useFileManagerShortcuts()\n\n  useEffect(() => {\n    initCollapsibleList()\n    initSortSettings()\n    initShowCloudFiles()\n  }, [])\n\n  return (\n    <div\n      ref={sidebarRef}\n      id=\"article-sidebar\"\n      className=\"w-full h-full flex flex-col outline-none\"\n      tabIndex={-1}\n    >\n      <div className=\"flex-1 overflow-x-hidden overflow-y-auto\">\n        <FileManager focusSidebar={focusSidebar} />\n      </div>\n      <FileFooter />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/mobile-action-menu.tsx",
    "content": "'use client'\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { MoreVertical } from \"lucide-react\"\nimport { useIsMobile } from \"@/hooks/use-mobile\"\n\ninterface MobileActionMenuProps {\n  children: React.ReactNode\n  className?: string\n}\n\nexport function MobileActionMenu({ children, className }: MobileActionMenuProps) {\n  const isMobile = useIsMobile()\n  \n  if (!isMobile) {\n    return null\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <div\n          className={`h-6 w-6 p-0 hover:bg-muted rounded flex items-center justify-center cursor-pointer ${className}`}\n          onClick={(e) => {\n            e.stopPropagation()\n            e.preventDefault()\n          }}\n        >\n          <MoreVertical className=\"h-4 w-4\" />\n        </div>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-48\">\n        {children}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\n// 重新导出 DropdownMenuItem 和 DropdownMenuSeparator 以便在菜单中使用\nexport { DropdownMenuItem as MobileMenuItem, DropdownMenuSeparator as MobileSeparator } from \"@/components/ui/dropdown-menu\"\n"
  },
  {
    "path": "src/app/core/main/file/root-drop.js",
    "content": "export function sanitizeDroppedFileName(fileName) {\n  return fileName.replace(/\\s+/g, '_')\n}\n\nexport async function writeDroppedFileToRoot(deps, payload) {\n  const sanitizedFileName = sanitizeDroppedFileName(deps.fileName)\n  const pathOptions = await deps.getFilePathOptions(sanitizedFileName)\n\n  if (payload.kind === 'text') {\n    await deps.writeTextFile?.(pathOptions.path, payload.content, pathOptions.baseDir ? { baseDir: pathOptions.baseDir } : undefined)\n  } else {\n    await deps.writeFile?.(pathOptions.path, payload.content, pathOptions.baseDir ? { baseDir: pathOptions.baseDir } : undefined)\n  }\n\n  return sanitizedFileName\n}\n"
  },
  {
    "path": "src/app/core/main/file/root-drop.spec.mjs",
    "content": "import test from 'node:test'\nimport assert from 'node:assert/strict'\n\nimport { sanitizeDroppedFileName, writeDroppedFileToRoot } from './root-drop.js'\n\ntest('sanitizes dropped filenames by replacing whitespace with underscores', () => {\n  assert.equal(sanitizeDroppedFileName('my note.md'), 'my_note.md')\n})\n\ntest('writes dropped text files using workspace path options', async () => {\n  const calls = []\n\n  await writeDroppedFileToRoot({\n    fileName: 'my note.md',\n    getFilePathOptions: async (relativePath) => {\n      calls.push(['getFilePathOptions', relativePath])\n      return { path: '/tmp/workspace/my_note.md' }\n    },\n    writeTextFile: async (path, content, options) => {\n      calls.push(['writeTextFile', path, content, options])\n    },\n  }, {\n    kind: 'text',\n    content: '# hello',\n  })\n\n  assert.deepEqual(calls, [\n    ['getFilePathOptions', 'my_note.md'],\n    ['writeTextFile', '/tmp/workspace/my_note.md', '# hello', undefined],\n  ])\n})\n\ntest('writes dropped binary files using default workspace baseDir options', async () => {\n  const bytes = new Uint8Array([1, 2, 3])\n  const calls = []\n\n  await writeDroppedFileToRoot({\n    fileName: 'cover image.png',\n    getFilePathOptions: async (relativePath) => {\n      calls.push(['getFilePathOptions', relativePath])\n      return { path: 'article/cover_image.png', baseDir: 'AppData' }\n    },\n    writeFile: async (path, content, options) => {\n      calls.push(['writeFile', path, Array.from(content), options])\n    },\n  }, {\n    kind: 'binary',\n    content: bytes,\n  })\n\n  assert.deepEqual(calls, [\n    ['getFilePathOptions', 'cover_image.png'],\n    ['writeFile', 'article/cover_image.png', [1, 2, 3], { baseDir: 'AppData' }],\n  ])\n})\n"
  },
  {
    "path": "src/app/core/main/file/vector-knowledge-menu.tsx",
    "content": "import { ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent } from \"@/components/ui/enhanced-context-menu\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Database, Trash2 } from \"lucide-react\"\nimport { Store } from '@tauri-apps/plugin-store'\nimport { toast } from \"@/hooks/use-toast\"\nimport useArticleStore, { DirTree } from \"@/stores/article\"\nimport { readTextFile } from \"@tauri-apps/plugin-fs\"\nimport { useState, useEffect } from \"react\"\nimport { useTranslations } from \"next-intl\"\nimport { computedParentPath } from \"@/lib/path\"\n\ninterface VectorKnowledgeMenuProps {\n  item: DirTree\n  hasVector: boolean\n  onVectorUpdated: () => void\n}\n\nexport function VectorKnowledgeMenu({ item, hasVector, onVectorUpdated }: VectorKnowledgeMenuProps) {\n  const t = useTranslations('article.file')\n  const { clearFileVector, checkFileVectorIndexed, setVectorCalcStatus } = useArticleStore()\n  const [autoCalcEnabled, setAutoCalcEnabled] = useState(true)\n  const [excludeFromKB, setExcludeFromKB] = useState(false)\n  const filePath = computedParentPath(item)\n\n  // 加载向量配置状态\n  useEffect(() => {\n    async function loadVectorSettings() {\n      const store = await Store.load('store.json')\n      const disabledFiles = await store.get<string[]>('vectorAutoCalcDisabled') || []\n      const excludedFiles = await store.get<string[]>('vectorExcludedFiles') || []\n      setAutoCalcEnabled(!disabledFiles.includes(filePath))\n      setExcludeFromKB(excludedFiles.includes(filePath))\n    }\n    loadVectorSettings()\n  }, [item])\n\n  async function handleVectorCalculation() {\n    if (!item.isFile) return\n\n    try {\n      // 设置为计算中状态\n      setVectorCalcStatus(filePath, 'calculating')\n\n      // 获取完整文件路径\n      const { getFilePathOptions } = await import('@/lib/workspace')\n      const pathOptions = await getFilePathOptions(filePath)\n\n      let content = ''\n      if (pathOptions.baseDir) {\n        content = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n      } else {\n        content = await readTextFile(pathOptions.path)\n      }\n\n      // 直接调用 RAG 库计算向量，与文件夹批量计算保持一致\n      const { processMarkdownFile } = await import('@/lib/rag')\n      await processMarkdownFile(filePath, content)\n\n      // 更新向量索引状态\n      await checkFileVectorIndexed(filePath)\n      onVectorUpdated()\n\n      // 设置为完成状态\n      setVectorCalcStatus(filePath, 'completed')\n\n      toast({ title: hasVector ? t('context.vectorCalculated') : t('context.vectorCalcCompleted') })\n    } catch (error) {\n      console.error('向量计算失败:', error)\n      // 失败时恢复为空闲状态\n      setVectorCalcStatus(filePath, 'idle')\n      toast({ title: t('context.vectorCalcFailed'), variant: 'destructive' })\n    }\n  }\n\n  async function handleDeleteVector() {\n    if (!item.isFile) return\n\n    try {\n      await clearFileVector(filePath)\n      onVectorUpdated()\n      toast({ title: t('context.vectorDeleted') })\n    } catch (error) {\n      console.error('删除向量失败:', error)\n      toast({ title: t('context.vectorDeleteFailed'), variant: 'destructive' })\n    }\n  }\n\n  async function handleToggleAutoCalc(checked: boolean) {\n    const store = await Store.load('store.json')\n    const disabledFiles = await store.get<string[]>('vectorAutoCalcDisabled') || []\n\n    if (checked) {\n      const index = disabledFiles.indexOf(filePath)\n      if (index > -1) {\n        disabledFiles.splice(index, 1)\n      }\n    } else {\n      if (!disabledFiles.includes(filePath)) {\n        disabledFiles.push(filePath)\n      }\n    }\n\n    await store.set('vectorAutoCalcDisabled', disabledFiles)\n    setAutoCalcEnabled(checked)\n  }\n\n  async function handleToggleExcludeFromKB(checked: boolean) {\n    const store = await Store.load('store.json')\n    const excludedFiles = await store.get<string[]>('vectorExcludedFiles') || []\n\n    if (checked) {\n      const index = excludedFiles.indexOf(filePath)\n      if (index > -1) {\n        excludedFiles.splice(index, 1)\n      }\n    } else {\n      if (!excludedFiles.includes(filePath)) {\n        excludedFiles.push(filePath)\n      }\n      if (hasVector) {\n        await clearFileVector(filePath)\n        onVectorUpdated()\n      }\n    }\n\n    await store.set('vectorExcludedFiles', excludedFiles)\n    setExcludeFromKB(!checked)\n  }\n\n  return (\n    <ContextMenuSub>\n      <ContextMenuSubTrigger inset menuType=\"file\">\n        <Database className=\"mr-2 h-4 w-4\" />\n        {t('context.knowledgeBase')}\n      </ContextMenuSubTrigger>\n      <ContextMenuSubContent>\n        <ContextMenuItem inset onClick={handleVectorCalculation} menuType=\"file\">\n          <Database className=\"mr-2 h-4 w-4\" />\n          {hasVector ? t('context.updateVectors') : t('context.calculateVectors')}\n        </ContextMenuItem>\n        <ContextMenuItem disabled={!hasVector} inset onClick={(e) => { e.stopPropagation(); handleDeleteVector(); }} menuType=\"file\" className=\"text-red-600\">\n          <Trash2 className=\"mr-2 h-4 w-4\" />\n          {t('context.deleteVectors')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <div className=\"flex items-center justify-between px-2 py-1.5 text-sm\" onClick={(e) => e.stopPropagation()}>\n          <span>{t('context.autoVectorCalc')}</span>\n          <Switch\n            checked={autoCalcEnabled}\n            onCheckedChange={handleToggleAutoCalc}\n            className=\"ml-4\"\n          />\n        </div>\n        <div className=\"flex items-center justify-between px-2 py-1.5 text-sm\" onClick={(e) => e.stopPropagation()}>\n          <span>{t('context.includeInKBFile')}</span>\n          <Switch\n            checked={!excludeFromKB}\n            onCheckedChange={handleToggleExcludeFromKB}\n            className=\"ml-4\"\n          />\n        </div>\n      </ContextMenuSubContent>\n    </ContextMenuSub>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/file/workspace-selector.tsx",
    "content": "'use client'\n\nimport { Select, SelectContent, SelectItem, SelectTrigger } from \"@/components/ui/select\"\nimport { FolderOpen } from \"lucide-react\"\nimport useSettingStore from \"@/stores/setting\"\nimport useArticleStore from \"@/stores/article\"\nimport { useTranslations } from 'next-intl'\nimport { useMemo } from \"react\"\n\nexport function WorkspaceSelector() {\n  const { workspacePath, workspaceHistory, setWorkspacePath } = useSettingStore()\n  const { clearCollapsibleList, loadFileTree, setActiveFilePath, setCurrentArticle } = useArticleStore()\n  const t = useTranslations('settings.file')\n\n  // 获取文件夹名称\n  const getWorkspaceName = (path: string) => {\n    if (!path) return t('workspace.defaultPath')\n    return path.split('/').pop() || path.split('\\\\').pop() || path\n  }\n\n  // 当前工作区名称\n  const currentWorkspaceName = useMemo(() => {\n    return getWorkspaceName(workspacePath)\n  }, [workspacePath, t])\n\n  // 切换工作区\n  async function handleWorkspaceChange(path: string) {\n    // 处理特殊的默认工作区值\n    const targetPath = path === '__default__' ? '' : path\n    if (targetPath === workspacePath) return\n    \n    try {\n      await setWorkspacePath(targetPath)\n      await clearCollapsibleList()\n      setActiveFilePath('')\n      setCurrentArticle('')\n      await loadFileTree()\n    } catch (error) {\n      console.error('切换工作区失败:', error)\n    }\n  }\n\n  return (\n    <div className=\"border-t bg-muted/30 h-6 flex items-center\">\n      <Select value={workspacePath} onValueChange={handleWorkspaceChange}>\n        <SelectTrigger className=\"h-6 border-0 bg-transparent hover:bg-transparent focus:ring-0 text-sm\">\n          <span className=\"truncate text-xs text-right\">{currentWorkspaceName}</span>\n        </SelectTrigger>\n        <SelectContent>\n          {/* 默认工作区 */}\n          <SelectItem value=\"__default__\">\n            <div className=\"flex items-center gap-2\">\n              <FolderOpen className=\"w-4 h-4\" />\n              <span>{t('workspace.defaultPath')}</span>\n            </div>\n          </SelectItem>\n          {/* 历史工作区 */}\n          {workspaceHistory.map((path, index) => (\n            <SelectItem key={index} value={path}>\n              <div className=\"flex items-center gap-2\">\n                <FolderOpen className=\"w-4 h-4\" />\n                <span>{getWorkspaceName(path)}</span>\n              </div>\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/left-sidebar.tsx",
    "content": "'use client'\n\nimport { Tabs, TabsContent } from \"@/components/ui/tabs\"\nimport { Files, Highlighter } from \"lucide-react\"\nimport { FileSidebar } from \"./file\"\nimport { NoteSidebar } from \"./mark\"\nimport { FileActions } from \"./file/file-actions\"\nimport { MarkActions } from \"./mark/mark-actions\"\nimport { useTranslations } from \"next-intl\"\nimport { useSidebarStore } from \"@/stores/sidebar\"\nimport { ExpandableTabs } from \"@/components/ui/expandable-tabs\"\nimport { AnimatePresence, motion } from \"framer-motion\"\n\nconst SIDEBAR_TABS = [\n  { title: \"files\", icon: Files },\n  { title: \"notes\", icon: Highlighter },\n] as const\n\nexport function LeftSidebar() {\n  const { leftSidebarTab, setLeftSidebarTab } = useSidebarStore()\n  const t = useTranslations()\n\n  const handleTabChange = (index: number | null) => {\n    if (index !== null) {\n      setLeftSidebarTab(SIDEBAR_TABS[index].title)\n    }\n  }\n\n  const getSelectedIndex = () => {\n    return SIDEBAR_TABS.findIndex(tab => tab.title === leftSidebarTab)\n  }\n\n  // Prepare tabs with translated titles\n  const tabs = SIDEBAR_TABS.map(tab => ({\n    ...tab,\n    title: t(`navigation.${tab.title === 'notes' ? 'record' : tab.title}`),\n  }))\n\n  return (\n    <div className=\"w-full h-full flex flex-col\">\n      <Tabs value={leftSidebarTab} className=\"w-full h-full flex flex-col\">\n        <div className=\"w-full h-12 border-b flex items-center justify-between px-2\">\n          <ExpandableTabs\n            tabs={tabs}\n            onChange={handleTabChange}\n            selected={getSelectedIndex()}\n          />\n          <div className=\"relative\">\n            <AnimatePresence mode=\"wait\">\n              {leftSidebarTab === \"files\" && (\n                <motion.div\n                  key=\"files-actions\"\n                  initial={{ opacity: 0, x: 10 }}\n                  animate={{ opacity: 1, x: 0 }}\n                  exit={{ opacity: 0, x: -10 }}\n                  transition={{ duration: 0.2 }}\n                >\n                  <FileActions />\n                </motion.div>\n              )}\n              {leftSidebarTab === \"notes\" && (\n                <motion.div\n                  key=\"notes-actions\"\n                  initial={{ opacity: 0, x: 10 }}\n                  animate={{ opacity: 1, x: 0 }}\n                  exit={{ opacity: 0, x: -10 }}\n                  transition={{ duration: 0.2 }}\n                >\n                  <MarkActions />\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        </div>\n        <TabsContent value=\"files\" className=\"flex-1 m-0 overflow-hidden\">\n          <FileSidebar />\n        </TabsContent>\n        <TabsContent value=\"notes\" className=\"flex-1 m-0 overflow-hidden\">\n          <NoteSidebar />\n        </TabsContent>\n      </Tabs>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/clipboard.tsx",
    "content": "'use client'\nimport { clear, hasImage, hasText, readImageBase64, readText } from \"tauri-plugin-clipboard-api\";\nimport { useTranslations } from 'next-intl';\nimport { useEffect, useState } from 'react';\nimport Image from 'next/image';\nimport { BaseDirectory, copyFile, exists, mkdir, readFile, writeFile } from '@tauri-apps/plugin-fs';\nimport useTagStore from \"@/stores/tag\";\nimport useSettingStore from \"@/stores/setting\";\nimport useMarkStore from \"@/stores/mark\";\nimport { v4 as uuid } from 'uuid'\nimport ocr from \"@/lib/ocr\";\nimport { fetchAiDesc, fetchAiDescByImage } from \"@/lib/ai/description\";\nimport { insertMark, Mark } from \"@/db/marks\";\nimport { uint8ArrayToBase64, uploadFile } from \"@/lib/sync/github\";\nimport { RepoNames } from \"@/lib/sync/github.types\";\nimport { CheckCircle, CircleX } from \"lucide-react\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { convertBytesToSize } from \"@/lib/utils\";\n\nexport function Clipboard() {\n  const t = useTranslations();\n  const [type, setType] = useState<'image' | 'text'>('image')\n  const [text, setText] = useState('')\n  const [image, setImage] = useState('')\n  const [fileSize, setFileSize] = useState('')\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { primaryModel, githubUsername, primaryImageMethod, enableImageRecognition } = useSettingStore()\n  const { fetchMarks, addQueue, setQueue, removeQueue } = useMarkStore()\n\n  async function readHandler() {\n    const hasImageRes = await hasImage()\n    const hasTextRes = await hasText()\n\n    if (hasImageRes) {\n      setType('image')\n      await handleImage()\n    } else if (hasTextRes) {\n      setType('text')\n      await handleText()\n    }\n  }\n\n  async function handleImage() {\n    const image = await readImageBase64()\n    const uint8Array = Uint8Array.from(atob(image), c => c.charCodeAt(0))\n    await writeFile('clipboard.png', uint8Array, { baseDir: BaseDirectory.AppData })\n    setFileSize(convertBytesToSize(uint8Array.length))\n    setImage(`data:image/png;base64, ${image}`)\n  }\n\n  async function handleText() {\n    const text = await readText()\n    setText(text)\n  }\n\n  async function handleInset() {\n    await clear()\n    setImage('')\n    const queueId = uuid()\n    // 获取文件后缀\n    addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.saveImage'), type: 'image', startTime: Date.now() })\n    const isImageFolderExists = await exists('image', { baseDir: BaseDirectory.AppData})\n    if (!isImageFolderExists) {\n      await mkdir('image', { baseDir: BaseDirectory.AppData})\n    }\n    await copyFile('clipboard.png', `image/${queueId}.png`, { fromPathBaseDir: BaseDirectory.AppData, toPathBaseDir: BaseDirectory.AppData})\n    let content = ''\n    let desc = ''\n    \n    // Skip image recognition if disabled\n    if (!enableImageRecognition) {\n      setQueue(queueId, { progress: t('record.mark.progress.save') });\n      content = ''\n      desc = ''\n    } else if (primaryImageMethod === 'vlm') {\n      // 使用 VLM 识别图片\n      setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n      const file = await readFile(`image/${queueId}.png`, { baseDir: BaseDirectory.AppData })\n      const base64 = `data:image/png;base64,${Buffer.from(file).toString('base64')}`\n      content = await fetchAiDescByImage(base64) || 'VLM Error'\n      desc = content\n    } else {\n      // 使用 OCR 识别图片\n      setQueue(queueId, { progress: t('record.mark.progress.ocr') });\n      content = await ocr(`image/${queueId}.png`)\n      setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n      if (primaryModel) {\n        desc = await fetchAiDesc(content).then(res => res ? res : content) || content\n      } else {\n        desc = content\n      }\n    }\n    const mark: Partial<Mark> = {\n      tagId: currentTagId,\n      type: 'image',\n      content,\n      url: `${queueId}.png`,\n      desc,\n    }\n    const file = await readFile(`image/${queueId}.png`, { baseDir: BaseDirectory.AppData  })\n    if (githubUsername) {\n      setQueue(queueId, { progress: t('record.mark.progress.uploadImage') });\n      const res = await uploadFile({\n        file: uint8ArrayToBase64(file),\n        filename: `${queueId}.png`,\n        repo: RepoNames.image\n      })\n      if (res) {\n        setQueue(queueId, { progress: t('record.mark.progress.jsdelivrCache') });\n        await fetch(`https://purge.jsdelivr.net/gh/${githubUsername}/${RepoNames.image}@main/${res.data.content.name}`)\n        mark.url = `https://cdn.jsdelivr.net/gh/${githubUsername}/${RepoNames.image}@main/${res.data.content.name}`\n      } else {\n        mark.url = `${queueId}.png}`\n      }\n    }\n    removeQueue(queueId)\n    await insertMark(mark)\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  async function handleTextInset() {\n    await clear()\n    setText('')\n    const mark: Partial<Mark> = {\n      tagId: currentTagId,\n      type: 'text',\n      content: text,\n      desc: text,\n    }\n    insertMark(mark)\n    fetchMarks()\n    fetchTags()\n    getCurrentTag()\n  }\n\n  async function handleCancle() {\n    setImage('')\n    setText('')\n    setFileSize('')\n    await clear()\n  }\n\n  useEffect(() => {\n    listen('tauri://focus', readHandler)\n  }, [])\n\n  return (\n    type === 'image' ? (\n      image && (\n        <div className=\"relative flex justify-center items-center\">\n          <div className=\"absolute top-0 left-0 flex gap-2 justify-between items-center mb-2 w-full z-20 p-4\">\n            <p className=\"text-sm font-bold text-white\">{t('record.mark.clipboard.detectedImage')}</p>\n            <div className=\"flex gap-2\">\n              <CircleX className=\"text-white size-4 cursor-pointer\" onClick={handleCancle} />\n              <CheckCircle className=\"text-white size-4 cursor-pointer\" onClick={handleInset} />\n            </div>\n          </div>\n          <p className=\"absolute bottom-4 right-4 z-20 text-xs text-white\">{fileSize}</p>\n          <div className=\"bg-primary opacity-70 w-full h-full absolute top-0 left-0 z-10\"></div>\n          <Image src={image} width={0} height={0} alt=\"clipboard image\" className=\"w-full object-cover\" />\n        </div>\n      )\n    ) : (\n      text && (\n        <div className=\"flex-col justify-center items-center p-4 bg-primary\">\n          <div className=\"flex gap-2 justify-between items-center mb-2\">\n            <p className=\"text-sm font-bold text-secondary\">{t('record.mark.clipboard.detectedText')}</p>\n            <div className=\"flex gap-2\">\n              <CircleX className=\"text-secondary size-4 cursor-pointer\" onClick={handleCancle} />\n              <CheckCircle className=\"text-secondary size-4 cursor-pointer\" onClick={handleTextInset} />\n            </div>\n          </div>\n          <p className=\"line-clamp-5 text-xs text-secondary mb-2\">{text}</p>\n          <p className=\"line-clamp-5 text-xs text-secondary text-right\">{t('record.mark.text.characterCount', { count: text.length })}</p>\n        </div>\n      )\n    )\n  )\n}"
  },
  {
    "path": "src/app/core/main/mark/control-file.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { FilePlus } from \"lucide-react\"\nimport { useTranslations } from 'next-intl'\nimport { open } from '@tauri-apps/plugin-dialog';\nimport { readTextFile } from \"@tauri-apps/plugin-fs\";\nimport useTagStore from \"@/stores/tag\";\nimport useMarkStore from \"@/stores/mark\";\nimport { insertMark } from \"@/db/marks\";\nimport { useEffect, useCallback } from 'react'\nimport emitter from '@/lib/emitter'\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\nimport { extractTextFromPDF } from '@/lib/pdf'\nimport { v4 as uuid } from 'uuid'\n\n// 常见的代码格式\nconst codeExtensions = [\n  // Web开发\n  'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'php', 'mjs', 'mts',\n  // 编程语言\n  'py', 'java', 'cpp', 'c', 'cs', 'go', 'rb', 'rs', 'swift', 'kt', 'scala', 'dart', 'lua', 'r',\n  // 标记/配置\n  'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'graphql', 'sql',\n  // Shell脚本\n  'sh', 'bash', 'zsh', 'fish', 'ps1',\n  // 其他\n  'asm', 'pl', 'clj', 'ex', 'elm', 'f90', 'hs', 'jl', 'swift', 'ml'\n];\nconst textFileExtensions = ['txt', 'md', 'csv'];\nconst pdfExtensions = ['pdf'];\nconst fileExtensions: string[] = []\n\nexport function ControlFile() {\n  const t = useTranslations();\n  const router = useRouter();\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks, addQueue, setQueue, removeQueue } = useMarkStore()\n\n  const handleSelectFile = useCallback(() => {\n    selectFile()\n  }, [])\n\n  useEffect(() => {\n    emitter.on('toolbar-shortcut-file', handleSelectFile)\n    return () => {\n      emitter.off('toolbar-shortcut-file', handleSelectFile)\n    }\n  }, [handleSelectFile])\n\n  async function selectFile() {\n    const filePath = await open({\n      multiple: false,\n      directory: false,\n      filters: [{\n        name: 'files',\n        extensions: [...textFileExtensions, ...fileExtensions, ...codeExtensions, ...pdfExtensions]\n      }]\n    });\n    if (!filePath) return\n    \n    // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n    handleRecordComplete(router)\n    \n    await readFileByPath(filePath)\n  }\n\n  async function readFileByPath(path: string) {\n    const ext = path.substring(path.lastIndexOf('.') + 1)\n    // 提取文件名（不含路径）\n    const fileName = path.split('/').pop() || path.split('\\\\').pop() || path\n    // 构建描述：文件名\n    const desc = fileName\n    let content = ''\n\n    // 处理 PDF 文件\n    if (pdfExtensions.includes(ext)) {\n      const queueId = uuid()\n      try {\n        addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.cacheFile'), type: 'file', startTime: Date.now() })\n        content = await extractTextFromPDF(path, (progress) => {\n          setQueue(queueId, { progress })\n        })\n        setQueue(queueId, { progress: t('record.mark.progress.save') })\n      } catch (error) {\n        console.error('PDF extraction failed:', error)\n        content = 'PDF 文本提取失败'\n      }\n      removeQueue(queueId)\n\n      // 将完整路径存储在 url 字段，用于点击时打开文件夹\n      await insertMark({\n        tagId: currentTagId,\n        type: 'file',\n        desc: desc,\n        content: content,\n        url: path\n      })\n      await fetchMarks()\n      await fetchTags()\n      getCurrentTag()\n      return\n    }\n    // 处理文本文件和代码文件\n    else if ([...textFileExtensions, ...codeExtensions].includes(ext)) {\n      content = await readTextFile(path)\n      content = content.replace(/'/g, '')\n    }\n    // 不支持的文件类型\n    else {\n      return\n    }\n\n    // 将完整路径存储在 url 字段，用于点击时打开文件夹\n    await insertMark({\n      tagId: currentTagId,\n      type: 'file',\n      desc: desc,\n      content: content,\n      url: path\n    })\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  return (\n    <TooltipButton icon={<FilePlus />} tooltipText={t('record.mark.type.file')} onClick={selectFile} />\n  )\n}"
  },
  {
    "path": "src/app/core/main/mark/control-image.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { insertMark, Mark } from \"@/db/marks\"\nimport { useTranslations } from 'next-intl'\nimport { fetchAiDesc, fetchAiDescByImage } from \"@/lib/ai/description\"\nimport ocr from \"@/lib/ocr\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { BaseDirectory, copyFile, exists, mkdir, readFile, writeFile } from \"@tauri-apps/plugin-fs\"\nimport { ImagePlus } from \"lucide-react\"\nimport useSettingStore from \"@/stores/setting\"\nimport { v4 as uuid } from 'uuid'\nimport { open } from '@tauri-apps/plugin-dialog';\nimport { uploadImage } from \"@/lib/imageHosting\"\nimport { useRef, useEffect, useCallback } from 'react'\nimport { isMobileDevice } from '@/lib/check'\nimport emitter from '@/lib/emitter'\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\n\nexport function ControlImage() {\n  const t = useTranslations();\n  const router = useRouter();\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { primaryModel, primaryImageMethod, enableImageRecognition } = useSettingStore()\n  const { fetchMarks, addQueue, setQueue, removeQueue } = useMarkStore()\n  const fileInputRef = useRef<HTMLInputElement>(null)\n  const isMobile = isMobileDevice()\n\n  const handleSelectImages = useCallback(() => {\n    selectImages()\n  }, [])\n\n  useEffect(() => {\n    emitter.on('toolbar-shortcut-image', handleSelectImages)\n    return () => {\n      emitter.off('toolbar-shortcut-image', handleSelectImages)\n    }\n  }, [handleSelectImages])\n\n  async function selectImages() {\n    try {\n      // 移动端使用 HTML5 file input\n      if (isMobile) {\n        if (fileInputRef.current) {\n          fileInputRef.current.click()\n        } else {\n          console.error('File input ref not available')\n        }\n        return\n      }\n\n      // PC端使用 Tauri dialog\n      const filePaths = await open({\n        multiple: true,\n        directory: false,\n        filters: [{\n          name: 'Image',\n          extensions: ['png', 'jpeg', 'jpg', 'gif', 'webp','svg', 'bmp', 'ico']\n        }]\n      });\n      if (!filePaths) return\n      \n      // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n      handleRecordComplete(router)\n      \n      filePaths.forEach(async (path) => {\n        await upload(path)\n      })\n    } catch (error) {\n      console.error('Error in selectImages:', error)\n    }\n  }\n\n  // 处理移动端文件选择\n  async function handleFileInputChange(event: React.ChangeEvent<HTMLInputElement>) {\n    try {\n      const files = event.target.files\n      if (!files || files.length === 0) {\n        return\n      }\n      \n      // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n      handleRecordComplete(router)\n      \n      for (let i = 0; i < files.length; i++) {\n        await uploadMobileFile(files[i])\n      }\n      \n      // 重置 input\n      event.target.value = ''\n    } catch (error) {\n      console.error('Error in handleFileInputChange:', error)\n    }\n  }\n\n  // 移动端文件上传\n  async function uploadMobileFile(file: File) {\n    const queueId = uuid()\n    \n    try {\n      addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.cacheImage'), type: 'image', startTime: Date.now() })\n      \n      const ext = file.name.substring(file.name.lastIndexOf('.') + 1) || 'jpg'\n      \n      const isImageFolderExists = await exists('image', { baseDir: BaseDirectory.AppData})\n      if (!isImageFolderExists) {\n        await mkdir('image', { baseDir: BaseDirectory.AppData})\n      }\n      \n      // 将文件保存到本地\n      const filename = `${queueId}.${ext}`\n      const arrayBuffer = await file.arrayBuffer()\n      const uint8Array = new Uint8Array(arrayBuffer)\n      await writeFile(`image/${filename}`, uint8Array, { baseDir: BaseDirectory.AppData })\n      \n      let content = ''\n      let desc = ''\n      \n      // Skip image recognition if disabled\n      if (!enableImageRecognition) {\n        setQueue(queueId, { progress: t('record.mark.progress.save') });\n        content = ''\n        desc = ''\n      } else if (primaryImageMethod === 'vlm') {\n        // 使用 VLM 识别图片\n        setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n        const base64 = await fileToBase64(file)\n        content = await fetchAiDescByImage(base64) || 'VLM Error'\n        desc = content\n      } else {\n        // 使用 OCR 识别图片\n        setQueue(queueId, { progress: t('record.mark.progress.ocr') });\n        content = await ocr(`image/${filename}`)\n        setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n        if (primaryModel) {\n          desc = await fetchAiDesc(content).then(res => res ? res : content) || content\n        } else {\n          desc = content\n        }\n      }\n      \n      const mark: Partial<Mark> = {\n        tagId: currentTagId,\n        type: 'image',\n        content,\n        url: filename,\n        desc,\n      }\n      \n      // 尝试上传图片到图床（如果配置了图床）\n      try {\n        const url = await uploadImage(file)\n        if (url) {\n          setQueue(queueId, { progress: t('record.mark.progress.uploadImage') });\n          mark.url = url\n        }\n      } catch (uploadError) {\n        console.error('Failed to upload to image hosting:', uploadError)\n        // 继续使用本地文件\n      }\n      \n      removeQueue(queueId)\n      await insertMark(mark)\n      await fetchMarks()\n      await fetchTags()\n      getCurrentTag()\n    } catch (error) {\n      console.error('Error in uploadMobileFile:', error)\n      removeQueue(queueId)\n      // 可以选择显示错误提示给用户\n    }\n  }\n\n  // 将文件转换为 base64\n  function fileToBase64(file: File): Promise<string> {\n    return new Promise((resolve, reject) => {\n      const reader = new FileReader()\n      reader.onload = () => resolve(reader.result as string)\n      reader.onerror = reject\n      reader.readAsDataURL(file)\n    })\n  }\n\n  async function upload(path: string) {\n    const queueId = uuid()\n    addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.cacheImage'), type: 'image', startTime: Date.now() })\n    const ext = path.substring(path.lastIndexOf('.') + 1)\n    const isImageFolderExists = await exists('image', { baseDir: BaseDirectory.AppData})\n    if (!isImageFolderExists) {\n      await mkdir('image', { baseDir: BaseDirectory.AppData})\n    }\n    await copyFile(path, `image/${queueId}.${ext}`, { toPathBaseDir: BaseDirectory.AppData})\n    const fileData = await readFile(path)\n    const filename = `${queueId}.${ext}`\n    let content = ''\n    let desc = ''\n    \n    // Skip image recognition if disabled\n    if (!enableImageRecognition) {\n      setQueue(queueId, { progress: t('record.mark.progress.save') });\n      content = ''\n      desc = ''\n    } else if (primaryImageMethod === 'vlm') {\n      // 使用 VLM 识别图片\n      setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n      const base64 = `data:image/${ext};base64,${Buffer.from(fileData).toString('base64')}`\n      content = await fetchAiDescByImage(base64) || 'VLM Error'\n      desc = content\n    } else {\n      // 使用 OCR 识别图片\n      setQueue(queueId, { progress: t('record.mark.progress.ocr') });\n      content = await ocr(`image/${filename}`)\n      setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n      if (primaryModel) {\n        desc = await fetchAiDesc(content).then(res => res ? res : content) || content\n      } else {\n        desc = content\n      }\n    }\n    \n    const mark: Partial<Mark> = {\n      tagId: currentTagId,\n      type: 'image',\n      content,\n      url: filename,\n      desc,\n    }\n    \n    // 尝试上传图片到图床（如果配置了图床）\n    const file = new File([new Uint8Array(fileData)], filename, { type: `image/${ext}` })\n    const url = await uploadImage(file)\n    if (url) {\n      setQueue(queueId, { progress: t('record.mark.progress.uploadImage') });\n      mark.url = url\n    }\n    \n    removeQueue(queueId)\n    await insertMark(mark)\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  return (\n    <>\n      {/* 移动端文件选择 */}\n      {isMobile && (\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"image/*\"\n          multiple\n          onChange={handleFileInputChange}\n          className=\"hidden\"\n        />\n      )}\n      <TooltipButton icon={<ImagePlus />} tooltipText={t('record.mark.type.image')} onClick={selectImages} />\n    </>\n  )\n}"
  },
  {
    "path": "src/app/core/main/mark/control-link.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { Label } from \"@/components/ui/label\"\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { Input } from \"@/components/ui/input\"\nimport { insertMark } from \"@/db/marks\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { Link, CircleX } from \"lucide-react\"\nimport { useState, useEffect, useCallback } from \"react\"\nimport { fetch } from '@tauri-apps/plugin-http'\nimport { v4 as uuidv4 } from 'uuid'\nimport emitter from '@/lib/emitter'\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\nimport { hasText, readText } from 'tauri-plugin-clipboard-api'\nimport { Store } from '@tauri-apps/plugin-store'\n\nexport function ControlLink() {\n  const t = useTranslations();\n  const router = useRouter();\n  const [open, setOpen] = useState(false);\n  const [url, setUrl] = useState('')\n  const [loading, setLoading] = useState(false)\n  const [autoReadClipboard, setAutoReadClipboard] = useState(true)\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks, addQueue, setQueue, removeQueue } = useMarkStore()\n\n  // 初始化时从 store 读取设置\n  useEffect(() => {\n    async function loadSetting() {\n      try {\n        const store = await Store.load('store.json')\n        const savedValue = await store.get<boolean>('autoReadClipboard')\n        if (savedValue !== null && savedValue !== undefined) {\n          setAutoReadClipboard(savedValue)\n        }\n      } catch {\n        // 忽略加载错误\n      }\n    }\n    loadSetting()\n  }, [])\n\n  // 保存设置到 store\n  const handleAutoReadChange = useCallback(async (checked: boolean) => {\n    setAutoReadClipboard(checked)\n    try {\n      const store = await Store.load('store.json')\n      await store.set('autoReadClipboard', checked)\n      // 如果勾选了 checkbox，立即读取剪贴板\n      if (checked) {\n        try {\n          const hasTextRes = await hasText()\n          if (hasTextRes) {\n            const clipboardText = await readText()\n            if (clipboardText && isValidUrl(clipboardText)) {\n              setUrl(clipboardText)\n            }\n          }\n        } catch {\n          // 忽略剪贴板读取错误\n        }\n      }\n    } catch {\n      // 忽略保存错误\n    }\n  }, [])\n\n  // 检查剪贴板中的链接\n  const checkClipboard = useCallback(async () => {\n    // 只有启用自动读取时才检查剪贴板\n    if (!autoReadClipboard) {\n      return\n    }\n\n    try {\n      const hasTextRes = await hasText()\n      if (hasTextRes) {\n        const clipboardText = await readText()\n        if (clipboardText && isValidUrl(clipboardText)) {\n          setUrl(clipboardText)\n        }\n      }\n    } catch {\n      // 如果读取失败（比如在 Web 环境），静默忽略\n    }\n  }, [autoReadClipboard])\n\n  const handleOpen = useCallback(async () => {\n    setOpen(true)\n    await checkClipboard()\n  }, [checkClipboard])\n\n  const handleOpenChange = useCallback(async (open: boolean) => {\n    setOpen(open)\n    if (open) {\n      await checkClipboard()\n    }\n  }, [checkClipboard])\n\n  useEffect(() => {\n    emitter.on('toolbar-shortcut-link', handleOpen)\n    return () => {\n      emitter.off('toolbar-shortcut-link', handleOpen)\n    }\n  }, [handleOpen])\n\n  // 检查是否是有效的 URL\n  function isValidUrl(text: string): boolean {\n    if (!text || text.trim().length === 0) return false\n    const trimmed = text.trim()\n    // 支持带或不带协议的 URL\n    const urlPattern = /^https?:\\/\\/.+/i\n    const domainPattern = /^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}/i\n    return urlPattern.test(trimmed) || domainPattern.test(trimmed)\n  }\n\n  // 清空输入框\n  function handleClear() {\n    setUrl('')\n  }\n\n  async function handleSuccess() {\n    if (!url) return\n    let targetUrl = url\n    if (!targetUrl.startsWith('http')) {\n      targetUrl = `https://${targetUrl}`\n      setUrl(targetUrl)\n    }\n    \n    setLoading(true)\n    const queueId = uuidv4()\n    \n    // 添加到队列中显示加载状态\n    addQueue({\n      queueId,\n      tagId: currentTagId!,\n      type: 'link',\n      progress: '0%',\n      startTime: Date.now()\n    })\n    \n    // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n    handleRecordComplete(router)\n    \n    try {\n      setQueue(queueId, { progress: '30%' });\n      \n      // 使用 Tauri 的 HTTP 插件获取页面内容\n      const response = await fetch(targetUrl, {\n        method: 'GET',\n      });\n      \n      if (!response.ok) {\n        throw new Error(`HTTP 错误: ${response.status}`);\n      }\n      \n      setQueue(queueId, { progress: '60%' });\n      \n      // 获取 HTML 内容\n      const html = await response.text();\n\n      // 创建一个 DOMParser 来解析 HTML\n      const pageContent = await parseHtmlContent(html, targetUrl);\n      \n      setQueue(queueId, { progress: '90%' });\n      \n      if (pageContent.error) {\n        throw new Error(pageContent.error);\n      }\n      \n      // 提取有用的内容\n      const { title, metaDesc, mainContent, bodyText } = pageContent;\n      \n      // 构建描述\n      const desc = `${title}\\n${metaDesc}`;\n      \n      // 构建内容（优先使用主要内容，如果没有则使用正文）\n      const content = mainContent || bodyText;\n      \n      // 保存到数据库\n      await insertMark({ \n        tagId: currentTagId, \n        type: 'link', \n        desc: desc, \n        content: content,\n        url: targetUrl \n      });\n      \n      await fetchMarks();\n      await fetchTags();\n      getCurrentTag();\n      \n      setUrl('');\n      setOpen(false);\n      \n    } catch (error) {\n      console.error('Error crawling page:', error);\n    } finally {\n      removeQueue(queueId);\n      setLoading(false);\n    }\n  }\n\n  // 在浏览器环境中解析 HTML 内容\n  function parseHtmlContent(html: string, url: string): Promise<any> {\n    return new Promise((resolve) => {\n      try {\n        // 创建一个临时的 div 元素\n        const parser = new DOMParser();\n        const doc = parser.parseFromString(html, 'text/html');\n        \n        // 获取页面标题\n        const title = doc.title || new URL(url).hostname;\n        \n        // 获取元描述\n        const metaDesc = doc.querySelector('meta[name=\"description\"]')?.getAttribute('content') || '';\n        \n        // 尝试获取主要内容\n        let mainContent = '';\n        const mainElement = doc.querySelector('main') || \n                           doc.querySelector('article') || \n                           doc.querySelector('#content') || \n                           doc.querySelector('.content');\n        \n        if (mainElement) {\n          mainContent = mainElement.textContent || '';\n        }\n        \n        // 获取所有文本内容作为备选\n        let bodyText = '';\n        if (doc.body) {\n          bodyText = doc.body.textContent || '';\n        }\n        \n        // 限制文本长度\n        if (mainContent.length > 10000) {\n          mainContent = mainContent.substring(0, 10000);\n        }\n        \n        if (bodyText.length > 10000) {\n          bodyText = bodyText.substring(0, 10000);\n        }\n        \n        resolve({\n          title,\n          metaDesc,\n          mainContent,\n          bodyText,\n          url\n        });\n      } catch (error) {\n        resolve({ \n          error: `解析 HTML 内容失败: ${error}`,\n          title: new URL(url).hostname,\n          metaDesc: '',\n          mainContent: '',\n          bodyText: '',\n          url\n        });\n      }\n    });\n  }\n\n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={open} onOpenChange={handleOpenChange}>\n          <DrawerTrigger asChild>\n            <TooltipButton icon={<Link />} tooltipText={t('record.mark.type.link') || '链接'} />\n          </DrawerTrigger>\n          <DrawerContent>\n            <DrawerHeader>\n              <DrawerTitle>{t('record.mark.link.title') || '链接记录'}</DrawerTitle>\n              <DrawerDescription>\n                {t('record.mark.link.description') || '输入网页链接，系统将自动爬取页面内容并保存'}\n              </DrawerDescription>\n            </DrawerHeader>\n            <div className=\"px-4\">\n              <div className=\"relative\">\n                <Input\n                  placeholder=\"https://example.com\"\n                  value={url}\n                  onChange={(e) => setUrl(e.target.value)}\n                  disabled={loading}\n                  className=\"pr-10\"\n                />\n                {url && !loading && (\n                  <button\n                    onClick={handleClear}\n                    className=\"absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 transition-colors\"\n                  >\n                    <CircleX className=\"w-4 h-4\" />\n                  </button>\n                )}\n              </div>\n            </div>\n            <DrawerFooter className=\"flex items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <Checkbox\n                  id=\"auto-read-clipboard-mobile\"\n                  checked={autoReadClipboard}\n                  onCheckedChange={(checked) => handleAutoReadChange(checked === true)}\n                  disabled={loading}\n                />\n                <Label\n                  htmlFor=\"auto-read-clipboard-mobile\"\n                  className=\"text-sm cursor-pointer\"\n                >\n                  {t('record.mark.link.autoReadClipboard') || '自动读取剪贴板链接'}\n                </Label>\n              </div>\n              <div className=\"flex items-center gap-4\">\n                <p className=\"text-sm text-zinc-500\">\n                  {loading ? '正在爬取页面内容...' : ''}\n                </p>\n                <Button\n                  type=\"submit\"\n                  onClick={handleSuccess}\n                  disabled={!url || loading}\n                >\n                  {loading ? '处理中...' : (t('record.mark.link.save') || '保存')}\n                </Button>\n              </div>\n            </DrawerFooter>\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={open} onOpenChange={handleOpenChange}>\n          <DialogTrigger asChild>\n            <TooltipButton icon={<Link />} tooltipText={t('record.mark.type.link') || '链接'} />\n          </DialogTrigger>\n          <DialogContent className=\"min-w-full md:min-w-[500px]\">\n            <DialogHeader>\n              <DialogTitle>{t('record.mark.link.title') || '链接记录'}</DialogTitle>\n              <DialogDescription>\n                {t('record.mark.link.description') || '输入网页链接，系统将自动爬取页面内容并保存'}\n              </DialogDescription>\n            </DialogHeader>\n            <div className=\"relative\">\n              <Input\n                placeholder=\"https://example.com\"\n                value={url}\n                onChange={(e) => setUrl(e.target.value)}\n                disabled={loading}\n                className=\"pr-10\"\n              />\n              {url && !loading && (\n                <button\n                  onClick={handleClear}\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 transition-colors\"\n                >\n                  <CircleX className=\"w-4 h-4\" />\n                </button>\n              )}\n            </div>\n            <DialogFooter className=\"flex items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <Checkbox\n                  id=\"auto-read-clipboard\"\n                  checked={autoReadClipboard}\n                  onCheckedChange={(checked) => handleAutoReadChange(checked === true)}\n                  disabled={loading}\n                />\n                <Label\n                  htmlFor=\"auto-read-clipboard\"\n                  className=\"text-sm cursor-pointer\"\n                >\n                  {t('record.mark.link.autoReadClipboard') || '自动读取剪贴板链接'}\n                </Label>\n              </div>\n              <div className=\"flex items-center gap-4\">\n                <p className=\"text-sm text-zinc-500\">\n                  {loading ? '正在爬取页面内容...' : ''}\n                </p>\n                <Button\n                  type=\"submit\"\n                  onClick={handleSuccess}\n                  disabled={!url || loading}\n                >\n                  {loading ? '处理中...' : (t('record.mark.link.save') || '保存')}\n                </Button>\n              </div>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/control-recording.tsx",
    "content": "import { insertMark } from \"@/db/marks\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport useSettingStore from \"@/stores/setting\"\nimport useRecordingStore from \"@/stores/recording\"\nimport { Mic } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { useTranslations } from 'next-intl'\nimport { toast } from '@/hooks/use-toast'\nimport { transcribeRecording } from '@/lib/audio'\nimport { useRouter } from 'next/navigation'\nimport { open } from '@tauri-apps/plugin-dialog'\nimport { readFile, writeFile, BaseDirectory, exists, mkdir } from '@tauri-apps/plugin-fs'\nimport { useRef } from 'react'\nimport { isMobileDevice } from '@/lib/check'\nimport { convertToWav } from '@/lib/audio-converter'\nimport { useEffect } from 'react'\nimport emitter from '@/lib/emitter'\nimport { handleRecordComplete } from '@/lib/record-navigation'\nimport { getTranscriptionFallbackMessage } from '@/lib/speech/transcription-fallback.ts'\n\nexport function ControlRecording() {\n  const t = useTranslations();\n  const router = useRouter();\n  const { sttModel } = useSettingStore();\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const isMobile = isMobileDevice();\n  const lastClickTime = useRef<number>(0);\n  const clickTimer = useRef<NodeJS.Timeout | null>(null);\n\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks, addQueue, removeQueue } = useMarkStore()\n  \n  // 大模型录音\n  const {\n    isRecording,\n    recordingDuration,\n    startRecording,\n    stopRecording,\n    cancelRecording,\n  } = useRecordingStore()\n  \n  // 监听快捷键\n  useEffect(() => {\n    const handleToggleRecording = () => {\n      if (isRecording) {\n        handleStop()\n      } else {\n        handleStart()\n      }\n    }\n    \n    emitter.on('toolbar-shortcut-recording', handleToggleRecording)\n    return () => {\n      emitter.off('toolbar-shortcut-recording', handleToggleRecording)\n    }\n  }, [isRecording])\n\n  // 格式化录音时长\n  const formatDuration = (seconds: number) => {\n    const mins = Math.floor(seconds / 60)\n    const secs = seconds % 60\n    return `${mins}:${secs.toString().padStart(2, '0')}`\n  }\n  \n  // 开始录音\n  const handleStart = async () => {\n    try {\n      await startRecording()\n      \n      // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n      handleRecordComplete(router)\n    } catch (error) {\n      cancelRecording()\n      toast({\n        title: t('recording.error'),\n        description: error instanceof Error ? error.message : t('recording.startError'),\n        variant: 'destructive'\n      })\n    }\n  }\n  \n  // 停止录音\n  const handleStop = async () => {\n    try {\n      const audioBlob = await stopRecording()\n      if (!audioBlob) {\n        throw new Error(t('recording.noAudioData'))\n      }\n      \n      // 转换为 WAV 格式\n      const wavBlob = await convertToWav(audioBlob)\n      \n      // 创建队列ID\n      const queueId = `recording-${Date.now()}`\n      \n      // 添加到队列中显示识别中的状态\n      addQueue({\n        queueId,\n        tagId: currentTagId,\n        type: 'recording',\n        progress: t('recording.processing'),\n        startTime: Date.now()\n      })\n      \n      // 后台异步识别（使用转换后的 WAV）\n      processTranscription(wavBlob, queueId)\n      \n    } catch (error) {\n      console.error('停止录音失败:', error)\n      toast({\n        title: t('recording.error'),\n        description: error instanceof Error ? error.message : t('recording.startError'),\n        variant: 'destructive'\n      })\n    }\n  }\n  \n  // 保存音频文件到本地\n  const saveAudioFile = async (audioBlob: Blob): Promise<string> => {\n    const timestamp = Date.now()\n    // 根据 MIME 类型确定文件扩展名\n    const extension = audioBlob.type.includes('wav') ? 'wav' :\n                      audioBlob.type.includes('mpeg') || audioBlob.type.includes('mp3') ? 'mp3' :\n                      audioBlob.type.includes('mp4') || audioBlob.type.includes('m4a') ? 'mp4' : \n                      audioBlob.type.includes('webm') ? 'webm' : \n                      audioBlob.type.includes('ogg') ? 'ogg' :\n                      audioBlob.type.includes('flac') ? 'flac' :\n                      audioBlob.type.includes('aac') ? 'aac' : 'webm'\n    const filename = `recording_${timestamp}.${extension}`\n    const audioDir = 'recordings'\n    \n    // 确保目录存在\n    const dirExists = await exists(audioDir, { baseDir: BaseDirectory.AppData })\n    if (!dirExists) {\n      await mkdir(audioDir, { baseDir: BaseDirectory.AppData, recursive: true })\n    }\n    \n    // 将 Blob 转换为 ArrayBuffer\n    const arrayBuffer = await audioBlob.arrayBuffer()\n    const uint8Array = new Uint8Array(arrayBuffer)\n    \n    // 保存文件\n    const filePath = `${audioDir}/${filename}`\n    await writeFile(filePath, uint8Array, { baseDir: BaseDirectory.AppData })\n    \n    return filePath\n  }\n  \n  // 后台处理识别\n  const processTranscription = async (\n    audioBlob: Blob,\n    queueId: string,\n  ) => {\n    let audioPath = ''\n    try {\n      // 先验证 Blob 是否有效\n      if (!audioBlob || audioBlob.size === 0) {\n        throw new Error('音频数据为空')\n      }\n      \n      // 保存音频文件\n      audioPath = await saveAudioFile(audioBlob)\n      \n      // 调用STT API识别\n      let transcription = ''\n      try {\n        transcription = await transcribeRecording(audioBlob)\n      } catch (error) {\n        console.error('STT识别出错:', error)\n      }\n      \n      // 无论是否识别成功，都保存记录\n      const noContent = !transcription || !transcription.trim()\n      const fallbackMessage = getTranscriptionFallbackMessage(sttModel)\n      const displayContent = noContent ? (fallbackMessage || t('recording.noContentDetected')) : transcription\n      \n      await insertMark({\n        tagId: currentTagId,\n        type: 'recording',\n        desc: displayContent.substring(0, 100),\n        content: displayContent,\n        url: audioPath  // 保存音频文件路径\n      })\n      \n      // 移除队列\n      removeQueue(queueId)\n      \n      // 刷新列表\n      await fetchMarks()\n      await fetchTags()\n      getCurrentTag()\n      \n      // 录制结束后不再显示提示\n    } catch (error) {\n      console.error('识别失败:', error)\n      \n      // 移除队列\n      removeQueue(queueId)\n      \n      toast({\n        title: t('recording.error'),\n        description: error instanceof Error ? error.message : t('recording.transcriptionError'),\n        variant: 'destructive'\n      })\n    } finally {\n    }\n  }\n  \n  // 选择音频文件并识别\n  const handleFileSelect = async () => {\n    try {\n      // 移动端使用 HTML5 file input\n      if (isMobile) {\n        fileInputRef.current?.click()\n        return\n      }\n\n      // PC端使用 Tauri dialog\n      const selected = await open({\n        multiple: false,\n        filters: [{\n          name: 'Audio',\n          extensions: ['mp3', 'wav', 'm4a', 'ogg', 'flac', 'aac', 'wma', 'webm']\n        }]\n      })\n\n      if (!selected) return\n\n      // 读取文件\n      const filePath = selected as string\n      const fileData = await readFile(filePath)\n      \n      // 根据文件扩展名确定 MIME 类型\n      const extension = filePath.split('.').pop()?.toLowerCase()\n      const mimeType = extension === 'wav' ? 'audio/wav' :\n                      extension === 'mp3' ? 'audio/mpeg' :\n                      extension === 'm4a' ? 'audio/mp4' :\n                      extension === 'mp4' ? 'audio/mp4' :\n                      extension === 'ogg' ? 'audio/ogg' :\n                      extension === 'webm' ? 'audio/webm' :\n                      'audio/mpeg'\n      \n      // 将 Uint8Array 转换为 ArrayBuffer\n      const buffer = fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength) as ArrayBuffer\n      const audioBlob = new Blob([buffer], { type: mimeType })\n\n      // 创建队列ID\n      const queueId = `recording-${Date.now()}`\n      \n      // 添加到队列中显示识别中的状态\n      addQueue({\n        queueId,\n        tagId: currentTagId,\n        type: 'recording',\n        progress: t('recording.processing'),\n        startTime: Date.now()\n      })\n      \n      // 后台异步识别\n      processTranscription(audioBlob, queueId)\n      \n    } catch (error) {\n      console.error('文件选择失败:', error)\n      toast({\n        title: t('recording.error'),\n        description: error instanceof Error ? error.message : '文件选择失败',\n        variant: 'destructive'\n      })\n    }\n  }\n  \n  // 处理移动端文件选择\n  const handleFileInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {\n    const file = event.target.files?.[0]\n    if (!file) return\n\n    try {\n      // 创建队列ID\n      const queueId = `recording-${Date.now()}`\n      \n      // 添加到队列中显示识别中的状态\n      addQueue({\n        queueId,\n        tagId: currentTagId,\n        type: 'recording',\n        progress: t('recording.processing'),\n        startTime: Date.now()\n      })\n      \n      // 后台异步识别（File 对象就是 Blob，直接传递）\n      processTranscription(file, queueId)\n      \n      // 重置 input\n      event.target.value = ''\n    } catch (error) {\n      console.error('文件处理失败:', error)\n      toast({\n        title: t('recording.error'),\n        description: error instanceof Error ? error.message : '文件处理失败',\n        variant: 'destructive'\n      })\n    }\n  }\n\n  // 处理点击事件（单击录音，双击选择文件）\n  const handleClick = () => {\n    const now = Date.now()\n    const timeSinceLastClick = now - lastClickTime.current\n    \n    // 双击判定：300ms内的第二次点击\n    if (timeSinceLastClick < 300 && timeSinceLastClick > 0) {\n      // 双击：取消单击的延迟执行，直接选择文件\n      if (clickTimer.current) {\n        clearTimeout(clickTimer.current)\n        clickTimer.current = null\n      }\n      lastClickTime.current = 0 // 重置，避免三连击\n      handleFileSelect()\n    } else {\n      // 单击：延迟执行，等待可能的第二次点击\n      lastClickTime.current = now\n      \n      // 清除之前的定时器\n      if (clickTimer.current) {\n        clearTimeout(clickTimer.current)\n      }\n      \n      // 延迟300ms执行单击操作，如果期间有第二次点击则会被取消\n      clickTimer.current = setTimeout(() => {\n        if (isRecording) {\n          handleStop()\n        } else {\n          handleStart()\n        }\n        clickTimer.current = null\n      }, 300)\n    }\n  }\n  \n  // 生成tooltip文本\n  const getTooltipText = () => {\n    if (isRecording) {\n      return `${t('recording.recording')} ${formatDuration(recordingDuration)}`\n    }\n    return `${t('record.mark.type.recording')} (${t('recording.doubleClickToSelectFile')})`\n  }\n\n  return (\n    <>\n      {/* 移动端文件选择 */}\n      {isMobile && (\n        <input\n          ref={fileInputRef}\n          type=\"file\"\n          accept=\"audio/*,.mp3,.wav,.m4a,.ogg,.flac,.aac,.wma,.webm\"\n          onChange={handleFileInputChange}\n          className=\"hidden\"\n        />\n      )}\n      \n      <Tooltip>\n        <TooltipTrigger asChild>\n        <Button \n          variant=\"ghost\" \n          size=\"icon\"\n          onClick={handleClick}\n          className={`relative ${isRecording ? 'text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950' : ''}`}\n        >\n          <Mic className=\"size-4\" />\n          {isRecording && (\n            <span className=\"absolute top-1 right-1 flex h-2 w-2\">\n              <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75\"></span>\n              <span className=\"relative inline-flex rounded-full h-2 w-2 bg-red-500\"></span>\n            </span>\n          )}\n        </Button>\n      </TooltipTrigger>\n      <TooltipContent>\n        <p>{getTooltipText()}</p>\n      </TooltipContent>\n      </Tooltip>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/control-scan.tsx",
    "content": "'use client'\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { useTranslations } from 'next-intl'\nimport { invoke } from \"@tauri-apps/api/core\"\nimport { ScanText } from \"lucide-react\"\nimport { convertFileSrc } from \"@tauri-apps/api/core\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport { useEffect, useState, useCallback } from \"react\"\nimport {\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselNext,\n  CarouselPrevious,\n} from \"@/components/ui/carousel\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { useRef } from \"react\";\nimport { ScreenshotImage } from \"note-gen/screenshot\"\nimport { BaseDirectory, writeFile } from \"@tauri-apps/plugin-fs\"\nimport Cropper from 'cropperjs';\nimport 'cropperjs/dist/cropper.css';\nimport Image from 'next/image'\nimport useTagStore from \"@/stores/tag\"\nimport useMarkStore from \"@/stores/mark\"\nimport { v4 as uuid } from \"uuid\"\nimport useSettingStore from \"@/stores/setting\"\nimport ocr from \"@/lib/ocr\"\nimport { fetchAiDesc, fetchAiDescByImage } from \"@/lib/ai/description\"\nimport { insertMark } from \"@/db/marks\"\nimport emitter from '@/lib/emitter'\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\n\nexport function ControlScan() {\n  const t = useTranslations();\n  const router = useRouter();\n  const [open, setOpen] = useState(false)\n  const [image, setImage] = useState<HTMLImageElement>();\n  const [files, setFiles] = useState<ScreenshotImage[]>([])\n  const cropperRef = useRef<Cropper | null>(null);\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks, addQueue, removeQueue, setQueue } = useMarkStore()\n  const { primaryModel, primaryImageMethod, enableImageRecognition } = useSettingStore()\n\n  function initCropper() {\n    if (cropperRef.current) {\n      cropperRef.current.destroy()\n    }\n    const image = document.getElementById('cropper') as HTMLImageElement;\n    if (!image) return\n    // 绑定双击事件\n    cropperRef.current = new Cropper(image, {\n      background: false,\n      viewMode: 1,\n      toggleDragModeOnDblclick: false\n    });\n    setTimeout(() => {\n      document.querySelector('.cropper-crop-box')?.addEventListener('dblclick', () => {\n        cropEnd()\n      })\n    }, 100)\n  }\n\n  async function createScreenShot() {\n    const fileNames = await invoke<ScreenshotImage[]>('screenshot')\n    const convertedFiles = fileNames.map((fileName: ScreenshotImage) => {\n      return {\n        ...fileName,\n        path: convertFileSrc(fileName.path),\n      }\n    })\n    setFiles(convertedFiles)\n    if (convertedFiles.length > 0) {\n      const image = new window.Image();\n      image.src = convertedFiles[0].path;\n      setImage(image)\n    }\n  }\n\n  function selectImage(file: ScreenshotImage) {\n    const image = new window.Image();\n    image.src = file.path;\n    setImage(image)\n  }\n\n  async function cropEnd() {\n    setOpen(false)\n    const queueId = uuid()\n    if (!cropperRef.current) return\n    const canvas = cropperRef.current.getCroppedCanvas();\n    canvas.toBlob(async (blob) => {\n      if (!blob) return\n      const arrayBuffer = await blob.arrayBuffer()\n      const uint8Array = new Uint8Array(arrayBuffer)\n      await writeFile(`screenshot/${queueId}.png`, uint8Array, {\n        baseDir: BaseDirectory.AppData\n      })\n      \n      // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n      handleRecordComplete(router)\n      \n      let content = ''\n      let desc = ''\n      \n      // Skip image recognition if disabled\n      if (!enableImageRecognition) {\n        addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.save'), type: 'scan', startTime: Date.now() })\n        content = ''\n        desc = ''\n      } else if (primaryImageMethod === 'vlm') {\n        addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.aiAnalysis'), type: 'scan', startTime: Date.now() })\n        const base64 = `data:image/png;base64,${Buffer.from(uint8Array).toString('base64')}`\n        content = await fetchAiDescByImage(base64) || 'VLM Error'\n        desc = content\n      } else {\n        addQueue({ queueId, tagId: currentTagId!, progress: t('record.mark.progress.ocr'), type: 'scan', startTime: Date.now() })\n        content = await ocr(`screenshot/${queueId}.png`) || 'OCR Error'\n        if (primaryModel) {\n          setQueue(queueId, { progress: t('record.mark.progress.aiAnalysis') });\n          desc = await fetchAiDesc(content).then(res => res ? res : content) || content\n        } else {\n          desc = content\n        }\n      }\n      setQueue(queueId, { progress: t('record.mark.progress.save') });\n      await insertMark({ tagId: currentTagId, type: 'scan', content, url: `${queueId}.png`, desc })\n      removeQueue(queueId)\n      await fetchMarks()\n      await fetchTags()\n      getCurrentTag()\n    })\n  };\n\n  useEffect(() => {\n    if (open) {\n      initCropper()\n    }\n  }, [image, open])\n\n  const handleScan = useCallback(() => {\n    createScreenShot()\n    setOpen(true)\n  }, [])\n\n  useEffect(() => {\n    emitter.on('toolbar-shortcut-scan', handleScan)\n    return () => {\n      emitter.off('toolbar-shortcut-scan', handleScan)\n    }\n  }, [handleScan])\n\n  return (\n    <div className=\"hidden md:block\">\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>\n          <TooltipButton icon={<ScanText />} tooltipText={t('record.mark.type.screenshot')} onClick={createScreenShot} />\n        </DialogTrigger>\n        <DialogContent className=\"max-w-screen h-screen text-white bg-black border-none flex flex-col items-center justify-center overflow-hidden\">\n          <div className=\"flex-1 overflow-hidden\">\n            {\n              image && (\n                <Image id=\"cropper\" className=\"size-full object-contain\" width={0} height={0} src={image.src} alt=\"\" />\n              )\n            }\n          </div>\n          <Carousel\n            opts={{\n              align: \"start\",\n            }}\n            orientation=\"horizontal\"\n            className=\"w-full max-w-xl h-24\"\n          >\n            <CarouselContent>\n              {files.map((file, index) => (\n                <CarouselItem key={index} className=\"pt-1 md:basis-1/5\">\n                  <Card\n                    className={`size-24 overflow-hidden cursor-pointer border-2 border-black ${image?.src === file.path ? 'border-white' : ''}`}\n                    onClick={() => selectImage(file)}\n                  >\n                    <CardContent className=\"flex relative items-center justify-center p-0 overflow-hidden size-full flex-col\">\n                      <Image className=\"size-full object-cover\" src={file.path} alt=\"\" width={200} height={200} />\n                      <p className=\"text-xs text-white line-clamp-1 text-center absolute bottom-0 left-0 right-0 bg-black bg-opacity-50\">{file.name}</p>\n                    </CardContent>\n                  </Card>\n                </CarouselItem>\n              ))}\n            </CarouselContent>\n            <CarouselPrevious className=\"text-white bg-black border-white\" />\n            <CarouselNext className=\"text-white bg-black border-white\" />\n          </Carousel>\n        </DialogContent>\n      </Dialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/control-text.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Button } from \"@/components/ui/button\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { Label } from \"@/components/ui/label\"\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { insertMark } from \"@/db/marks\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { CopySlash } from \"lucide-react\"\nimport { useEffect, useState, useCallback, useRef } from \"react\"\nimport emitter from \"@/lib/emitter\"\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\nimport { hasText, readText } from 'tauri-plugin-clipboard-api'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { toast } from '@/hooks/use-toast'\n\nexport function ControlText() {\n  const t = useTranslations();\n  const router = useRouter();\n  const [open, setOpen] = useState(false);\n  const [text, setText] = useState('')\n  const [autoReadClipboard, setAutoReadClipboard] = useState(true)\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n  const onboardingPrefillRef = useRef<string | null>(null)\n\n  const { currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const { fetchMarks } = useMarkStore()\n\n  // 初始化时从 store 读取设置\n  useEffect(() => {\n    async function loadSetting() {\n      try {\n        const store = await Store.load('store.json')\n        const savedValue = await store.get<boolean>('autoReadClipboard')\n        if (savedValue !== null && savedValue !== undefined) {\n          setAutoReadClipboard(savedValue)\n        }\n      } catch {\n        // 忽略加载错误\n      }\n    }\n    loadSetting()\n  }, [])\n\n  // 保存设置到 store\n  const handleAutoReadChange = useCallback(async (checked: boolean) => {\n    setAutoReadClipboard(checked)\n    try {\n      const store = await Store.load('store.json')\n      await store.set('autoReadClipboard', checked)\n      // 如果勾选了 checkbox，立即读取剪贴板\n      if (checked) {\n        try {\n          const hasTextRes = await hasText()\n          if (hasTextRes) {\n            const clipboardText = await readText()\n            if (clipboardText) {\n              setText(clipboardText)\n            }\n          }\n        } catch {\n          // 忽略剪贴板读取错误\n        }\n      }\n    } catch {\n      // 忽略保存错误\n    }\n  }, [])\n\n  // 检查剪贴板中的文本\n  const checkClipboard = useCallback(async () => {\n    if (onboardingPrefillRef.current) {\n      setText(onboardingPrefillRef.current)\n      return\n    }\n\n    // 只有启用自动读取时才检查剪贴板\n    if (!autoReadClipboard) {\n      return\n    }\n\n    try {\n      const hasTextRes = await hasText()\n      if (hasTextRes) {\n        const clipboardText = await readText()\n        if (clipboardText) {\n          setText(clipboardText)\n        }\n      }\n    } catch {\n      // 忽略剪贴板读取错误\n    }\n  }, [autoReadClipboard])\n\n  async function handleSuccess() {\n    const resetText = text.replace(/'/g, '').trim()\n\n    if (!resetText) {\n      toast({\n        title: t('common.warning'),\n        description: t('record.mark.text.description'),\n        variant: 'destructive',\n      })\n      return\n    }\n\n    try {\n      const store = await Store.load('store.json')\n      await store.set('currentTagId', currentTagId)\n      await store.save()\n\n      await insertMark({ tagId: currentTagId, type: 'text', desc: resetText, content: resetText })\n      await fetchMarks()\n      await fetchTags()\n      getCurrentTag()\n      emitter.emit('onboarding-step-complete', { step: 'create-record' })\n      emitter.emit('onboarding-record-prefill-changed', {})\n\n      // 记录完成后的导航处理（桌面端切换tab，移动端跳转页面）\n      handleRecordComplete(router)\n\n      setText('')\n      setOpen(false)\n    } catch (error) {\n      console.error('Failed to save text record:', error)\n      toast({\n        title: t('common.error'),\n        description: error instanceof Error ? error.message : t('common.error'),\n        variant: 'destructive',\n      })\n    }\n  }\n\n  const handleOpen = useCallback(async (payload?: { prefillText?: string }) => {\n    if (payload?.prefillText) {\n      onboardingPrefillRef.current = payload.prefillText\n    }\n    setOpen(true)\n    await checkClipboard()\n  }, [checkClipboard])\n\n  const handleOpenChange = useCallback(async (open: boolean) => {\n    setOpen(open)\n    if (open) {\n      await checkClipboard()\n    }\n  }, [checkClipboard])\n\n  useEffect(() => {\n    const handleOnboardingPrefillChange = (payload?: { prefillText?: string }) => {\n      onboardingPrefillRef.current = payload?.prefillText || null\n    }\n    const handleShortcutOpen = () => {\n      void handleOpen()\n    }\n\n    emitter.on('quickRecordTextHandler', handleOpen)\n    emitter.on('toolbar-shortcut-text', handleShortcutOpen)\n    emitter.on('onboarding-record-prefill-changed', handleOnboardingPrefillChange)\n    return () => {\n      emitter.off('quickRecordTextHandler', handleOpen)\n      emitter.off('toolbar-shortcut-text', handleShortcutOpen)\n      emitter.off('onboarding-record-prefill-changed', handleOnboardingPrefillChange)\n    }\n  }, [handleOpen])\n\n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={open} onOpenChange={handleOpenChange}>\n          <DrawerTrigger asChild>\n            <TooltipButton buttonId=\"onboarding-target-record-text\" icon={<CopySlash />} tooltipText={t('record.mark.type.text')} />\n          </DrawerTrigger>\n          <DrawerContent>\n            <DrawerHeader>\n              <DrawerTitle>{t('record.mark.text.title')}</DrawerTitle>\n              <DrawerDescription>\n                {t('record.mark.text.description')}\n              </DrawerDescription>\n            </DrawerHeader>\n            <div className=\"px-4\">\n              <Textarea\n                id=\"username\"\n                rows={10}\n                value={text}\n                onChange={(e) => setText(e.target.value)}\n              />\n            </div>\n            <DrawerFooter className=\"flex items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <Checkbox\n                  id=\"auto-read-clipboard-mobile\"\n                  checked={autoReadClipboard}\n                  onCheckedChange={(checked) => handleAutoReadChange(checked === true)}\n                />\n                <Label\n                  htmlFor=\"auto-read-clipboard-mobile\"\n                  className=\"text-sm cursor-pointer\"\n                >\n                  {t('record.mark.text.autoReadClipboard')}\n                </Label>\n              </div>\n              <div className=\"flex items-center gap-4\">\n                <p className=\"text-sm text-zinc-500\">{t('record.mark.text.characterCount', { count: text.length })}</p>\n                <Button type=\"submit\" onClick={handleSuccess}>{t('record.mark.text.save')}</Button>\n              </div>\n            </DrawerFooter>\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={open} onOpenChange={handleOpenChange}>\n          <DialogTrigger asChild>\n            <TooltipButton buttonId=\"onboarding-target-record-text\" icon={<CopySlash />} tooltipText={t('record.mark.type.text')} />\n          </DialogTrigger>\n          <DialogContent className=\"min-w-full md:min-w-[650px]\">\n            <DialogHeader>\n              <DialogTitle>{t('record.mark.text.title')}</DialogTitle>\n              <DialogDescription>\n                {t('record.mark.text.description')}\n              </DialogDescription>\n            </DialogHeader>\n            <Textarea\n              id=\"username\"\n              rows={10}\n              value={text}\n              onChange={(e) => setText(e.target.value)}\n            />\n            <DialogFooter className=\"flex items-center justify-between gap-4\">\n              <div className=\"flex items-center gap-2\">\n                <Checkbox\n                  id=\"auto-read-clipboard\"\n                  checked={autoReadClipboard}\n                  onCheckedChange={(checked) => handleAutoReadChange(checked === true)}\n                />\n                <Label\n                  htmlFor=\"auto-read-clipboard\"\n                  className=\"text-sm cursor-pointer\"\n                >\n                  {t('record.mark.text.autoReadClipboard')}\n                </Label>\n              </div>\n              <div className=\"flex items-center gap-4\">\n                <p className=\"text-sm text-zinc-500\">{t('record.mark.text.characterCount', { count: text.length })}</p>\n                <Button type=\"submit\" onClick={handleSuccess}>{t('record.mark.text.save')}</Button>\n              </div>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/control-todo.tsx",
    "content": "import { TooltipButton } from \"@/components/tooltip-button\"\nimport { Button } from \"@/components/ui/button\"\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { insertMark } from \"@/db/marks\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { CheckSquare } from \"lucide-react\"\nimport { useState, useCallback, useEffect } from \"react\"\nimport emitter from \"@/lib/emitter\"\nimport { useRouter } from 'next/navigation'\nimport { handleRecordComplete } from '@/lib/record-navigation'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\nimport { TodoForm, TodoFormData } from \"./todo-form\"\n\nexport function ControlTodo() {\n  const t = useTranslations();\n  const router = useRouter();\n  const [open, setOpen] = useState(false);\n  const [formData, setFormData] = useState<TodoFormData>({\n    title: '',\n    description: '',\n    priority: 'medium'\n  })\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n\n  const { currentTagId, fetchTags, getCurrentTag, tags } = useTagStore()\n  const { fetchMarks } = useMarkStore()\n  const [selectedTagId, setSelectedTagId] = useState<number>(currentTagId)\n\n  async function handleSuccess() {\n    if (!formData.title.trim()) {\n      return\n    }\n\n    const todoData = {\n      title: formData.title.trim(),\n      description: formData.description.trim(),\n      priority: formData.priority\n    }\n\n    await insertMark({\n      tagId: selectedTagId,\n      type: 'todo',\n      desc: formData.title.trim(),\n      content: JSON.stringify(todoData),\n      url: ''\n    })\n\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n\n    handleRecordComplete(router)\n\n    setFormData({\n      title: '',\n      description: '',\n      priority: 'medium'\n    })\n    setOpen(false)\n  }\n\n  const handleOpen = useCallback(() => {\n    setOpen(true)\n  }, [])\n\n  const handleOpenChange = useCallback((open: boolean) => {\n    setOpen(open)\n  }, [])\n\n  useEffect(() => {\n    emitter.on('toolbar-shortcut-todo', handleOpen)\n    return () => {\n      emitter.off('toolbar-shortcut-todo', handleOpen)\n    }\n  }, [handleOpen])\n\n  // Sync selectedTagId with currentTagId when dialog opens\n  useEffect(() => {\n    if (open) {\n      setSelectedTagId(currentTagId)\n    }\n  }, [open, currentTagId])\n\n  const formContent = (\n    <TodoForm\n      mode=\"create\"\n      data={formData}\n      onChange={setFormData}\n      selectedTagId={selectedTagId}\n      onTagChange={setSelectedTagId}\n      tags={tags}\n      showTagSelector={true}\n    />\n  )\n\n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={open} onOpenChange={handleOpenChange}>\n          <DrawerTrigger asChild>\n            <TooltipButton icon={<CheckSquare />} tooltipText={t('record.mark.type.todo')} />\n          </DrawerTrigger>\n          <DrawerContent>\n            <DrawerHeader>\n              <DrawerTitle>{t('record.mark.todo.title')}</DrawerTitle>\n              <DrawerDescription>\n                {t('record.mark.todo.description')}\n              </DrawerDescription>\n            </DrawerHeader>\n            <div className=\"px-4\">\n              {formContent}\n            </div>\n            <DrawerFooter>\n              <Button\n                type=\"submit\"\n                onClick={handleSuccess}\n                disabled={!formData.title.trim()}\n                className=\"w-full\"\n              >\n                {t('record.mark.todo.save')}\n              </Button>\n            </DrawerFooter>\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={open} onOpenChange={handleOpenChange}>\n          <DialogTrigger asChild>\n            <TooltipButton icon={<CheckSquare />} tooltipText={t('record.mark.type.todo')} />\n          </DialogTrigger>\n          <DialogContent className=\"min-w-full md:min-w-[650px]\">\n            <DialogHeader>\n              <DialogTitle>{t('record.mark.todo.title')}</DialogTitle>\n              <DialogDescription>\n                {t('record.mark.todo.description')}\n              </DialogDescription>\n            </DialogHeader>\n            {formContent}\n            <DialogFooter>\n              <Button\n                type=\"submit\"\n                onClick={handleSuccess}\n                disabled={!formData.title.trim()}\n              >\n                {t('record.mark.todo.save')}\n              </Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/crop.css",
    "content": ".cropper-bg{\n  background-image: none !important;\n  background-color: #000 !important;\n}\n\n.cropper-line{\n  background-color: #fff !important;\n}"
  },
  {
    "path": "src/app/core/main/mark/image-gallery.tsx",
    "content": "'use client'\n\nimport { Mark, delMark, updateMark } from \"@/db/marks\"\nimport { useState, useEffect } from \"react\"\nimport { cn, convertImage } from \"@/lib/utils\"\nimport { PhotoProvider, PhotoView } from \"react-photo-view\"\nimport { LocalImage } from \"@/components/local-image\"\nimport { useTranslations } from \"next-intl\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { appDataDir } from \"@tauri-apps/api/path\"\nimport { open } from \"@tauri-apps/plugin-shell\"\nimport { toast } from \"@/hooks/use-toast\"\nimport { fetchAiDesc } from \"@/lib/ai/description\"\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\"\nimport Image from \"next/image\"\n\ninterface ImageGalleryProps {\n  marks: Mark[]\n}\n\n// 单个图片项组件\nfunction ImageItem({ mark }: { mark: Mark }) {\n  const t = useTranslations()\n  const { fetchMarks } = useMarkStore()\n  const { tags, currentTagId, fetchTags, getCurrentTag } = useTagStore()\n  const [photoSrc, setPhotoSrc] = useState('')\n  const imagePath = mark.type === 'scan' \n    ? `/screenshot/${mark.url}`\n    : `/image/${mark.url}`\n\n  useEffect(() => {\n    async function loadImage() {\n      if (mark.url.includes('http')) {\n        setPhotoSrc(mark.url)\n      } else {\n        const converted = await convertImage(imagePath)\n        setPhotoSrc(converted)\n      }\n    }\n    loadImage()\n  }, [mark.url, imagePath])\n\n  async function handleDelMark(e?: React.MouseEvent) {\n    e?.stopPropagation()\n    await delMark(mark.id)\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  async function handleTransfer(tagId: number, e?: React.MouseEvent) {\n    e?.stopPropagation()\n    await updateMark({ ...mark, tagId })\n    await fetchTags()\n    getCurrentTag()\n    fetchMarks()\n  }\n\n  async function regenerateDesc(e?: React.MouseEvent) {\n    e?.stopPropagation()\n    const desc = await fetchAiDesc(mark.content || '') || ''\n    await updateMark({ ...mark, desc })\n    fetchMarks()\n  }\n\n  async function handelShowInFolder(e?: React.MouseEvent) {\n    e?.stopPropagation()\n    const appDir = await appDataDir()\n    const path = mark.type === 'scan' ? 'screenshot' : 'image'\n    open(`${appDir}/${path}`)\n  }\n\n  async function handelShowInFile(e?: React.MouseEvent) {\n    e?.stopPropagation()\n    const appDir = await appDataDir()\n    const path = mark.type === 'scan' ? 'screenshot' : 'image'\n    let filename = mark.url\n    if (mark.url.includes('http')) {\n      filename = mark.url.split('/').pop() || '';\n    }\n    open(`${appDir}/${path}/${filename}`)\n  }\n\n  async function handleCopyLink(e?: React.MouseEvent) {\n    e?.stopPropagation()\n    await navigator.clipboard.writeText(mark.url)\n    toast({\n      title: t('record.mark.toolbar.copied')\n    })\n  }\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger>\n        <PhotoProvider>\n          <PhotoView src={photoSrc}>\n            <div className=\"aspect-square overflow-hidden rounded cursor-pointer bg-zinc-900\">\n              {mark.url.includes('http') ? (\n                <Image\n                  src={mark.url}\n                  alt=\"\"\n                  width={0}\n                  height={0}\n\n                  className=\"w-full h-full object-cover\"\n                />\n              ) : (\n                <LocalImage\n                  src={imagePath}\n                  alt=\"\"\n                  className=\"w-full h-full object-cover\"\n                />\n              )}\n            </div>\n          </PhotoView>\n        </PhotoProvider>\n      </ContextMenuTrigger>\n      <ContextMenuContent>\n        <ContextMenuSub>\n          <ContextMenuSubTrigger inset>\n            {t('record.mark.toolbar.moveTag')}\n          </ContextMenuSubTrigger>\n          <ContextMenuSubContent>\n            {tags.map((tag) => (\n              <ContextMenuItem \n                disabled={tag.id === currentTagId} \n                key={tag.id} \n                onClick={() => handleTransfer(tag.id)}\n              >\n                {tag.name}\n              </ContextMenuItem>\n            ))}\n          </ContextMenuSubContent>\n        </ContextMenuSub>\n        <ContextMenuItem inset disabled={true}>\n          {t('record.mark.toolbar.convertTo', { type: mark.type === 'scan' ? t('record.mark.type.image') : t('record.mark.type.screenshot') })}\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={!mark.url} onClick={handleCopyLink}>\n          {t('record.mark.toolbar.copyLink')}\n        </ContextMenuItem>\n        <ContextMenuItem inset onClick={regenerateDesc}>\n          {t('record.mark.toolbar.regenerateDesc')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem inset onClick={handelShowInFolder}>\n          {t('record.mark.toolbar.viewFolder')}\n        </ContextMenuItem>\n        <ContextMenuItem inset onClick={handelShowInFile}>\n          {t('record.mark.toolbar.viewFile')}\n        </ContextMenuItem>\n        <ContextMenuItem inset onClick={handleDelMark}>\n          <span className=\"text-red-900\">\n            {t('record.mark.toolbar.delete')}\n          </span>\n        </ContextMenuItem>\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n}\n\nexport function ImageGallery({ marks }: ImageGalleryProps) {\n  const t = useTranslations()\n  const [isExpanded, setIsExpanded] = useState(false)\n\n  // 筛选出没有内容的图片记录（包括 scan 和 image 类型）\n  const emptyImageMarks = marks.filter(mark => \n    (mark.type === 'image' || mark.type === 'scan') && \n    (!mark.content || mark.content.trim() === '')\n  )\n\n  // 如果没有无内容的图片，不显示组件\n  if (emptyImageMarks.length === 0) {\n    return null\n  }\n\n  return (\n    <div>\n      <div \n        className=\"flex items-center justify-between px-2 py-2 cursor-pointer group\"\n        onClick={() => setIsExpanded(!isExpanded)}\n      >\n        <div className=\"flex items-center gap-2\">\n          <div className=\"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400\">\n            <span className=\"text-xs font-medium\">\n              图片组\n            </span>\n            <span className=\"text-xs opacity-70\">\n              {emptyImageMarks.length}\n            </span>\n          </div>\n        </div>\n        <div className=\"text-muted-foreground group-hover:text-foreground transition-colors\">\n          {isExpanded ? (\n            <span className=\"text-xs\">{t('record.mark.imageGallery.collapse')}</span>\n          ) : (\n            <span className=\"text-xs\">{t('record.mark.imageGallery.expand')}</span>\n          )}\n        </div>\n      </div>\n\n      {/* 图片展示区域 */}\n      <div className={cn(\n        \"px-2 pb-2\",\n        !isExpanded && \"max-h-[72px] overflow-hidden\"\n      )}>\n        <div \n          className={cn(\n            \"grid gap-2\",\n            !isExpanded && \"grid-rows-1\"\n          )}\n          style={{\n            gridTemplateColumns: `repeat(auto-fill, minmax(56px, 1fr))`\n          }}\n        >\n          {emptyImageMarks.map((mark) => (\n            <ImageItem key={mark.id} mark={mark} />\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/index.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport React from \"react\"\nimport { useEffect } from \"react\"\nimport { TagManage } from './tag-manage'\nimport { MarkList } from './mark-list'\nimport { MarkToolbar } from './mark-toolbar'\nimport useMarkStore from \"@/stores/mark\"\nimport { Button } from \"@/components/ui/button\"\nimport { clearTrash } from \"@/db/marks\"\nimport { confirm } from '@tauri-apps/plugin-dialog';\nimport { filterMarks } from \"./mark-filters.mjs\";\n\nexport function NoteSidebar() {\n  const t = useTranslations();\n  const { trashState, marks, setMarks, recordFilters, initRecordViewMode } = useMarkStore()\n  const visibleTrashMarks = React.useMemo(() => filterMarks(marks, recordFilters), [marks, recordFilters])\n\n  useEffect(() => {\n    initRecordViewMode()\n  }, [initRecordViewMode])\n\n  async function handleClearTrash() {\n    const res = await confirm(t('record.trash.confirm'), {\n      title: t('record.trash.title'),\n      kind: 'warning',\n    })\n    if (res) {\n      await clearTrash()\n      setMarks([])\n    }\n  }\n\n  return (\n    <div id=\"record-sidebar\" className=\"w-full h-full hidden md:flex flex-col\">\n      {trashState ? (\n        <>\n          <div className=\"flex p-2 border-b items-center justify-between\">\n            <p className=\"text-xs text-zinc-500\">{t('record.trash.records', { count: visibleTrashMarks.length })}</p>\n            {marks.length > 0 && (\n              <Button variant=\"ghost\" size=\"sm\" onClick={handleClearTrash}>\n                {t('record.trash.empty')}\n              </Button>\n            )}\n          </div>\n          <MarkList />\n        </>\n      ) : (\n        <div className=\"flex-1 overflow-y-auto\">\n          <TagManage />\n        </div>\n      )}\n      \n      <MarkToolbar />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-actions.tsx",
    "content": "\"use client\"\n\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport { Trash2, XCircle, Sparkles } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport useMarkStore from \"@/stores/mark\"\nimport { OrganizeNotes } from \"./organize-notes\"\nimport { useEffect, useRef } from \"react\"\nimport { MarkFilterPopover } from \"./mark-filter-popover\"\n\nexport function MarkActions() {\n  const t = useTranslations('record.mark')\n  const { trashState, setTrashState, initRecordFilters } = useMarkStore()\n  const organizeRef = useRef<{ openOrganize: () => void }>(null)\n\n  useEffect(() => {\n    initRecordFilters()\n  }, [initRecordFilters])\n\n  const handleToggleTrash = () => {\n    setTrashState(!trashState)\n  }\n\n  const handleOrganize = () => {\n    organizeRef.current?.openOrganize()\n  }\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {!trashState && (\n        <TooltipButton \n          buttonId=\"onboarding-target-organize-notes\"\n          icon={<Sparkles className=\"h-4 w-4\" />} \n          tooltipText={t('toolbar.organizeNotes')} \n          onClick={handleOrganize}\n          variant=\"ghost\"\n          side=\"bottom\"\n        />\n      )}\n      <MarkFilterPopover />\n      <TooltipButton \n        icon={trashState ? <XCircle className=\"h-4 w-4\" /> : <Trash2 className=\"h-4 w-4\" />} \n        tooltipText={trashState ? t('toolbar.closeTrash') : t('toolbar.trash')} \n        onClick={handleToggleTrash}\n        variant={trashState ? \"default\" : \"ghost\"}\n        side=\"bottom\"\n      />\n      <OrganizeNotes ref={organizeRef} />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-empty.tsx",
    "content": "import { Highlighter } from \"lucide-react\";\nimport { useTranslations } from 'next-intl';\n\nexport default function MarkEmpty() {\n  const t = useTranslations();\n  return <div className=\"flex flex-col justify-center items-center flex-1 w-full pt-32\">\n    <Highlighter className=\"size-16 opacity-10 mb-2\" />\n    <p className='text-zinc-500 opacity-30'>{t('record.mark.empty')}</p>\n  </div>\n}"
  },
  {
    "path": "src/app/core/main/mark/mark-filter-popover.tsx",
    "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { useTranslations } from \"next-intl\"\nimport { Filter, RotateCcw, Search } from \"lucide-react\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Toggle } from \"@/components/ui/toggle\"\nimport { Label } from \"@/components/ui/label\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport useMarkStore, { RecordTimePreset } from \"@/stores/mark\"\nimport { cn } from \"@/lib/utils\"\nimport { getMarkTypeChipClasses, MARK_TYPE_OPTIONS } from \"./mark-type-meta\"\nconst TIME_OPTIONS: RecordTimePreset[] = ['all', 'today', 'last7Days', 'last30Days']\n\nexport function MarkFilterPopover() {\n  const [open, setOpen] = useState(false)\n  const t = useTranslations('record.mark')\n  const {\n    recordFilters,\n    setRecordSearch,\n    toggleRecordType,\n    setRecordTimePreset,\n    resetRecordFilters,\n    hasActiveRecordFilters,\n  } = useMarkStore()\n\n  const isActive = hasActiveRecordFilters()\n\n  const handleClear = () => {\n    resetRecordFilters()\n    setOpen(false)\n  }\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <PopoverTrigger asChild>\n              <Button\n                variant={isActive ? \"default\" : \"ghost\"}\n                size=\"icon\"\n                className=\"relative\"\n                aria-label={t('toolbar.filter.title')}\n              >\n                <Filter className=\"h-4 w-4\" />\n                {isActive ? (\n                  <span className=\"absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-background/90\" />\n                ) : null}\n              </Button>\n            </PopoverTrigger>\n          </TooltipTrigger>\n          <TooltipContent side=\"bottom\">\n            <p>{t('toolbar.filter.title')}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n      <PopoverContent side=\"right\" align=\"start\" sideOffset={12} className=\"w-[320px] rounded-xl border-border/60 bg-popover/95 p-4 shadow-lg\">\n        <div className=\"space-y-4\">\n          <div className=\"space-y-1\">\n            <div className=\"text-sm font-semibold\">{t('toolbar.filter.title')}</div>\n            <p className=\"text-xs text-muted-foreground\">{t('toolbar.filter.description')}</p>\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"record-filter-search\" className=\"text-xs uppercase tracking-wide text-muted-foreground\">{t('toolbar.filter.search')}</Label>\n            <div className=\"relative\">\n              <Search className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n              <Input\n                id=\"record-filter-search\"\n                value={recordFilters.search}\n                onChange={(event) => setRecordSearch(event.target.value)}\n                placeholder={t('toolbar.filter.searchPlaceholder')}\n                className=\"pl-9\"\n              />\n            </div>\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label className=\"text-xs uppercase tracking-wide text-muted-foreground\">{t('toolbar.filter.time')}</Label>\n            <div className=\"flex flex-wrap gap-2\">\n              {TIME_OPTIONS.map((preset) => (\n                <Toggle\n                  key={preset}\n                  pressed={recordFilters.timePreset === preset}\n                  size=\"sm\"\n                  onClick={() => setRecordTimePreset(preset)}\n                  className={cn(\n                    \"h-8 rounded-full border px-3 text-xs font-medium shadow-none\",\n                    recordFilters.timePreset === preset\n                      ? \"border-primary/30 bg-primary/8 text-foreground hover:bg-primary/10\"\n                      : \"border-border/70 bg-muted/35 text-muted-foreground hover:bg-muted/60 hover:text-foreground\"\n                  )}\n                >\n                  {t(`toolbar.filter.timeOptions.${preset}`)}\n                </Toggle>\n              ))}\n            </div>\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label className=\"text-xs uppercase tracking-wide text-muted-foreground\">{t('toolbar.filter.type')}</Label>\n            <div className=\"flex flex-wrap gap-2\">\n              {MARK_TYPE_OPTIONS.map((type) => (\n                <Toggle\n                  key={type}\n                  pressed={recordFilters.selectedTypes.includes(type)}\n                  onPressedChange={() => toggleRecordType(type)}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className={cn(\n                    \"h-8 rounded-full px-3 text-xs font-medium shadow-none\",\n                    getMarkTypeChipClasses(type, recordFilters.selectedTypes.includes(type))\n                  )}\n                  aria-label={t(`type.${type}`)}\n                >\n                  {t(`type.${type}`)}\n                </Toggle>\n              ))}\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"flex justify-end\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleClear}\n              disabled={!isActive}\n              className=\"gap-2\"\n            >\n              <RotateCcw className=\"h-3.5 w-3.5\" />\n              {t('toolbar.filter.clear')}\n            </Button>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-filters.mjs",
    "content": "const DAY_IN_MS = 24 * 60 * 60 * 1000\nconst VALID_TYPES = new Set(['scan', 'text', 'image', 'link', 'file', 'recording', 'todo'])\nconst VALID_TIME_PRESETS = new Set(['all', 'today', 'last7Days', 'last30Days'])\n\nfunction normalizeText(value) {\n  return (value || '').trim().toLowerCase()\n}\n\nfunction matchesTimePreset(createdAt, timePreset, now) {\n  if (timePreset === 'all') {\n    return true\n  }\n\n  const createdTime = new Date(createdAt).getTime()\n  const nowDate = new Date(now)\n\n  if (Number.isNaN(createdTime) || Number.isNaN(nowDate.getTime())) {\n    return false\n  }\n\n  if (timePreset === 'today') {\n    return new Date(createdAt).toDateString() === nowDate.toDateString()\n  }\n\n  const diffMs = nowDate.getTime() - createdTime\n  if (diffMs < 0) {\n    return false\n  }\n\n  if (timePreset === 'last7Days') {\n    return diffMs <= 7 * DAY_IN_MS\n  }\n\n  if (timePreset === 'last30Days') {\n    return diffMs <= 30 * DAY_IN_MS\n  }\n\n  return true\n}\n\nfunction matchesSearch(mark, search) {\n  if (!search) {\n    return true\n  }\n\n  const haystack = [mark.content, mark.desc, mark.url]\n    .map((value) => normalizeText(value))\n    .join(' ')\n\n  return haystack.includes(search)\n}\n\nexport function normalizeRecordFilters(filters) {\n  const search = typeof filters?.search === 'string' ? filters.search : ''\n  const selectedTypes = Array.isArray(filters?.selectedTypes)\n    ? filters.selectedTypes.filter((type) => VALID_TYPES.has(type))\n    : []\n  const timePreset = VALID_TIME_PRESETS.has(filters?.timePreset) ? filters.timePreset : 'all'\n  const parsedTagId = typeof filters?.tagId === 'string' ? Number(filters.tagId) : filters?.tagId\n  const tagId = Number.isInteger(parsedTagId) && parsedTagId > 0 ? parsedTagId : 'all'\n\n  return {\n    search,\n    selectedTypes,\n    timePreset,\n    tagId,\n  }\n}\n\nexport function buildRecordFilterSummary(filters) {\n  const normalized = normalizeRecordFilters(filters)\n\n  return {\n    hasFilters: Boolean(\n      normalized.search.trim() ||\n      normalized.selectedTypes.length > 0 ||\n      normalized.timePreset !== 'all' ||\n      normalized.tagId !== 'all'\n    ),\n    search: normalized.search.trim(),\n    typeCount: normalized.selectedTypes.length,\n    timePreset: normalized.timePreset,\n    hasTag: normalized.tagId !== 'all',\n  }\n}\n\nexport function filterMarks(marks, filters) {\n  const normalizedFilters = normalizeRecordFilters(filters)\n  const search = normalizeText(normalizedFilters.search)\n  const selectedTypes = new Set(normalizedFilters.selectedTypes)\n  const timePreset = normalizedFilters.timePreset\n  const tagId = normalizedFilters.tagId\n  const now = filters?.now || new Date().toISOString()\n\n  return marks.filter((mark) => {\n    if (selectedTypes.size > 0 && !selectedTypes.has(mark.type)) {\n      return false\n    }\n\n    if (tagId !== 'all' && mark.tagId !== tagId) {\n      return false\n    }\n\n    if (!matchesTimePreset(mark.createdAt, timePreset, now)) {\n      return false\n    }\n\n    return matchesSearch(mark, search)\n  })\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-filters.spec.mjs",
    "content": "import test from 'node:test'\nimport assert from 'node:assert/strict'\n\nimport { buildRecordFilterSummary, filterMarks, normalizeRecordFilters } from './mark-filters.mjs'\n\nconst baseMarks = [\n  {\n    id: 1,\n    type: 'text',\n    tagId: 1,\n    content: 'Weekly planning notes',\n    desc: '',\n    url: '',\n    createdAt: '2026-03-17T09:00:00.000Z',\n  },\n  {\n    id: 2,\n    type: 'recording',\n    tagId: 2,\n    content: 'Daily standup recording',\n    desc: 'Team sync',\n    url: 'audio/standup.m4a',\n    createdAt: '2026-03-15T03:00:00.000Z',\n  },\n  {\n    id: 3,\n    type: 'link',\n    tagId: 1,\n    content: '',\n    desc: 'Reference article',\n    url: 'https://example.com/design-systems',\n    createdAt: '2026-02-01T08:00:00.000Z',\n  },\n]\n\ntest('filters marks by search text, type, tag, and time presets', () => {\n  const result = filterMarks(baseMarks, {\n    search: 'team',\n    selectedTypes: ['recording'],\n    timePreset: 'last7Days',\n    tagId: 2,\n    now: '2026-03-17T12:00:00.000Z',\n  })\n\n  assert.deepEqual(result.map((mark) => mark.id), [2])\n})\n\ntest('matches search text against content, description, and url', () => {\n  const byContent = filterMarks(baseMarks, {\n    search: 'planning',\n    selectedTypes: [],\n    timePreset: 'all',\n    tagId: 'all',\n    now: '2026-03-17T12:00:00.000Z',\n  })\n  const byDesc = filterMarks(baseMarks, {\n    search: 'reference',\n    selectedTypes: [],\n    timePreset: 'all',\n    tagId: 'all',\n    now: '2026-03-17T12:00:00.000Z',\n  })\n  const byUrl = filterMarks(baseMarks, {\n    search: 'design-systems',\n    selectedTypes: [],\n    timePreset: 'all',\n    tagId: 'all',\n    now: '2026-03-17T12:00:00.000Z',\n  })\n\n  assert.deepEqual(byContent.map((mark) => mark.id), [1])\n  assert.deepEqual(byDesc.map((mark) => mark.id), [3])\n  assert.deepEqual(byUrl.map((mark) => mark.id), [3])\n})\n\ntest('supports today and last30Days time presets', () => {\n  const todayOnly = filterMarks(baseMarks, {\n    search: '',\n    selectedTypes: [],\n    timePreset: 'today',\n    tagId: 'all',\n    now: '2026-03-17T12:00:00.000Z',\n  })\n  const last30Days = filterMarks(baseMarks, {\n    search: '',\n    selectedTypes: [],\n    timePreset: 'last30Days',\n    tagId: 'all',\n    now: '2026-03-17T12:00:00.000Z',\n  })\n\n  assert.deepEqual(todayOnly.map((mark) => mark.id), [1])\n  assert.deepEqual(last30Days.map((mark) => mark.id), [1, 2])\n})\n\ntest('normalizes persisted record filters and drops invalid values', () => {\n  const normalized = normalizeRecordFilters({\n    search: '  sync  ',\n    selectedTypes: ['recording', 'unknown', 'text'],\n    timePreset: 'last7Days',\n    tagId: '2',\n  })\n\n  assert.deepEqual(normalized, {\n    search: '  sync  ',\n    selectedTypes: ['recording', 'text'],\n    timePreset: 'last7Days',\n    tagId: 2,\n  })\n})\n\ntest('builds a compact summary payload for active filters', () => {\n  const summary = buildRecordFilterSummary({\n    search: '  sync  ',\n    selectedTypes: ['recording', 'text'],\n    timePreset: 'last7Days',\n    tagId: 2,\n  })\n\n  assert.deepEqual(summary, {\n    hasFilters: true,\n    search: 'sync',\n    typeCount: 2,\n    timePreset: 'last7Days',\n    hasTag: true,\n  })\n})\n"
  },
  {
    "path": "src/app/core/main/mark/mark-header.tsx",
    "content": "\"use client\"\nimport { useTranslations } from 'next-intl'\nimport * as React from \"react\"\nimport { initMarksDb } from \"@/db/marks\"\nimport { ControlScan } from \"./control-scan\"\nimport { ControlText } from \"./control-text\"\nimport { ControlImage } from \"./control-image\"\nimport { ControlFile } from \"./control-file\"\nimport { ControlLink } from \"./control-link\"\nimport { ControlRecording } from \"./control-recording\"\nimport { ControlTodo } from \"./control-todo\"\nimport useMarkStore from \"@/stores/mark\"\nimport useSettingStore from \"@/stores/setting\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Button } from \"@/components/ui/button\"\nimport { TooltipProvider } from '@/components/ui/tooltip'\nimport { Menu, Trash2, XCircle } from 'lucide-react'\nimport {\n  DndContext,\n  closestCenter,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  horizontalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\nexport function MarkHeader() {\n  const t = useTranslations('record.mark');\n  const { trashState, setTrashState, fetchAllTrashMarks, fetchMarks } = useMarkStore()\n  const { recordToolbarConfig, setRecordToolbarConfig } = useSettingStore()\n\n  // 拖拽传感器配置（仅桌面端）\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        delay: 500, // 按住500ms后才开始拖拽，避免误触点击事件\n        tolerance: 5, // 允许5px的移动误差\n      },\n    })\n  )\n\n  // 处理拖拽结束\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const oldIndex = recordToolbarConfig.findIndex((item) => item.id === active.id)\n      const newIndex = recordToolbarConfig.findIndex((item) => item.id === over.id)\n      \n      const newItems = arrayMove(recordToolbarConfig, oldIndex, newIndex)\n      // 更新 order\n      const updatedItems = newItems.map((item, index) => ({\n        ...item,\n        order: index\n      }))\n      setRecordToolbarConfig(updatedItems)\n    }\n  }\n\n  React.useEffect(() => {\n    initMarksDb()\n  }, [])\n\n  React.useEffect(() => {\n    if (trashState) {\n      fetchAllTrashMarks()\n    } else {\n      fetchMarks()\n    }\n  }, [trashState])\n\n  return (\n    <div className=\"flex justify-between items-center h-12 border-b px-2\">\n      {/* 工具栏 */}\n      <div className=\"flex\">\n        <TooltipProvider>\n          <DndContext\n            sensors={sensors}\n            collisionDetection={closestCenter}\n            onDragEnd={handleDragEnd}\n          >\n            <SortableContext\n              items={recordToolbarConfig.filter(item => item.enabled).map(item => item.id)}\n              strategy={horizontalListSortingStrategy}\n            >\n              <div className=\"flex\">\n                {recordToolbarConfig\n                  .filter(item => item.enabled)\n                  .sort((a, b) => a.order - b.order)\n                  .map(item => (\n                    <SortableToolbarItem key={item.id} id={item.id} />\n                  ))}\n              </div>\n            </SortableContext>\n          </DndContext>\n        </TooltipProvider>\n      </div>\n\n      {/* 菜单按钮 */}\n      <div className=\"flex items-center gap-1\">\n        {trashState ? (\n          <Button variant=\"ghost\" size=\"icon\" onClick={() => setTrashState(false)}>\n            <XCircle />\n          </Button>\n        ) : (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon\">\n                <Menu />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem onClick={() => setTrashState(true)}>\n                <Trash2 />{t('toolbar.trash')}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n    </div>\n  )\n}\n\n// 可排序的工具栏项组件\ninterface SortableToolbarItemProps {\n  id: string\n}\n\nfunction SortableToolbarItem({ id }: SortableToolbarItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  // 渲染对应的工具栏组件\n  const renderToolbarItem = () => {\n    switch (id) {\n      case 'text':\n        return <ControlText />\n      case 'recording':\n        return <ControlRecording />\n      case 'scan':\n        return <ControlScan />\n      case 'image':\n        return <ControlImage />\n      case 'link':\n        return <ControlLink />\n      case 'file':\n        return <ControlFile />\n      case 'todo':\n        return <ControlTodo />\n      default:\n        return null\n    }\n  }\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      className=\"cursor-grab active:cursor-grabbing\"\n    >\n      {renderToolbarItem()}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-item.tsx",
    "content": "'use client'\nimport React from \"react\"\nimport { delMark, delMarkForever, Mark, restoreMark, updateMark } from \"@/db/marks\";\nimport { useTranslations } from 'next-intl';\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n  ContextMenuSub,\n  ContextMenuSubTrigger,\n  ContextMenuSubContent\n} from \"@/components/ui/enhanced-context-menu\"\nimport dayjs from \"dayjs\";\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport useMarkStore from \"@/stores/mark\";\nimport useTagStore from \"@/stores/tag\";\nimport { LocalImage } from \"@/components/local-image\";\nimport { fetchAiDesc } from \"@/lib/ai/description\";\nimport { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from \"@/components/ui/sheet\";\nimport { appDataDir } from \"@tauri-apps/api/path\";\nimport { CheckSquare, ImageUp, RefreshCw, Settings2, Square } from \"lucide-react\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { AudioPlayer } from \"@/components/audio-player\";\nimport { ImageViewer } from \"@/components/image-viewer\";\nimport ChatPreview from \"../chat/chat-preview\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { MarkMobileActions } from \"./mark-mobile-actions\";\nimport { markToMarkdown } from \"@/lib/mark-to-markdown\";\nimport useSettingStore from \"@/stores/setting\";\nimport { TodoItemContent } from \"./todo-item-content\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { BaseDirectory, readFile } from \"@tauri-apps/plugin-fs\";\nimport { useRouter } from \"next/navigation\";\nimport { NO_TRANSCRIPTION_MESSAGE, transcribeRecording } from \"@/lib/audio\";\nimport { getMarkTypeListBadgeClasses } from \"./mark-type-meta\";\nimport { getMarkListItemContent } from \"./mark-list-item-content\";\nimport { TodoEditTrigger } from \"./todo-edit-button\";\n\ndayjs.extend(relativeTime)\n\n// Memoize line height mapping function\nconst getLineHeight = (textSize: string): string => {\n  const heightMap: Record<string, string> = {\n    'xs': 'leading-3',\n    'sm': 'leading-4',\n    'md': 'leading-5',\n    'lg': 'leading-6',\n    'xl': 'leading-7'\n  }\n  return heightMap[textSize] || 'leading-4'\n}\n\n// Memoize image size mapping function\nconst getImageSize = (textSize: string): string => {\n  const sizeMap: Record<string, string> = {\n    'xs': 'max-h-16',\n    'sm': 'max-h-20',\n    'md': 'max-h-24',\n    'lg': 'max-h-32',\n    'xl': 'max-h-40'\n  }\n  return sizeMap[textSize] || 'max-h-24'\n}\n\n// Memoize word count function\nconst getWordCount = (text: string): number => {\n  if (!text) return 0;\n  return text.replace(/\\s/g, '').length;\n};\n\nconst DetailViewer = React.memo(({mark, content, path, className}: {mark: Mark, content: string, path?: string, className?: string}) => {\n  const [value, setValue] = useState('')\n  const [descValue, setDescValue] = useState('')\n  const { updateMark } = useMarkStore()\n  const { recordTextSize } = useSettingStore()\n  const t = useTranslations('record.mark.type');\n  const markT = useTranslations('record.mark');\n  const messageControlT = useTranslations('record.mark.mark.chat.messageControl');\n\n  const lineHeight = useMemo(() => getLineHeight(recordTextSize), [recordTextSize])\n  const imageSize = useMemo(() => getImageSize(recordTextSize), [recordTextSize])\n\n  const textDescChangeHandler = useCallback(async (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    setDescValue(e.target.value)\n    await updateMark({ ...mark, desc: e.target.value })\n  }, [mark, updateMark])\n\n  const textMarkChangeHandler = useCallback(async (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    setValue(e.target.value)\n    await updateMark({ ...mark, desc: e.target.value, content: e.target.value })\n  }, [mark, updateMark])\n\n  useEffect(() => {\n    setValue(mark.content || '')\n    setDescValue(mark.desc?.trim() || '')\n  }, [mark])\n  return (\n    <Sheet>\n      <SheetTrigger asChild>\n        <span className={className || `line-clamp-2 ${lineHeight} mt-2 text-${recordTextSize} break-words cursor-pointer hover:underline`}>{content}</span>\n      </SheetTrigger>\n      <SheetContent className=\"lg:min-w-[800px] w-full mt-[env(safe-area-inset-top)] p-0\">\n        <SheetHeader className=\"p-4 border-b\">\n          <SheetTitle>{t(mark.type)}</SheetTitle>\n          <div className=\"flex items-center gap-2\">\n            <span className={`text-${recordTextSize} text-zinc-500`}>{markT('createdAt')}：{dayjs(mark.createdAt).format('YYYY-MM-DD HH:mm:ss')}</span>\n            <span className={`text-${recordTextSize} text-zinc-500`}>\n              {getWordCount(value)} {messageControlT('words')}\n            </span>\n          </div>\n        </SheetHeader>\n        <div className=\"h-[calc(100vh-88px)] overflow-y-auto md:p-8 p-2\">\n          {\n            mark.url && (mark.type === 'image' || mark.type === 'scan') ?\n            <LocalImage\n              src={mark.url.includes('http') ? mark.url : `/${path}/${mark.url}`}\n              alt=\"\"\n              className={`w-full ${imageSize} object-contain`}\n            /> :\n            null\n          }\n          {\n            mark.type === 'text' || mark.desc === mark.content ? null :\n            <>\n              <span className=\"block my-4 text-md text-zinc-900 font-bold\">{markT('desc')}</span>\n              <Textarea placeholder=\"在此输入文本记录内容...\" rows={3} value={descValue} onChange={textDescChangeHandler} />\n            </>\n          }\n          <span className=\"block my-4 text-md text-zinc-900 font-bold\">{markT('content')}</span>\n          {\n            mark.type === \"text\" ? \n            <Textarea placeholder=\"在此输入文本记录内容...\" rows={14} value={value} onChange={textMarkChangeHandler} /> :\n            <ChatPreview text={mark.content || ''} />\n          }\n        </div>\n      </SheetContent>\n    </Sheet>\n  )\n})\nDetailViewer.displayName = 'DetailViewer'\n\nexport type MarkItemVariant = 'list' | 'compact' | 'cards'\n\nexport const MarkWrapper = React.memo(({mark, variant = 'list'}: {mark: Mark, variant?: MarkItemVariant}) => {\n  const t = useTranslations('record.mark.type');\n  const todoT = useTranslations('record.mark.todo');\n  const recordingT = useTranslations('recording');\n  const { isMultiSelectMode, selectedMarkIds, toggleMarkSelection } = useMarkStore();\n  const { recordTextSize, sttModel } = useSettingStore();\n  const { fetchMarks } = useMarkStore();\n  const router = useRouter();\n  const isMobile = useIsMobile();\n  const [isRetryingTranscription, setIsRetryingTranscription] = useState(false);\n\n  const lineHeight = useMemo(() => getLineHeight(recordTextSize), [recordTextSize])\n  const shouldShowRecordingAction = mark.type === 'recording' && mark.content === NO_TRANSCRIPTION_MESSAGE\n  const itemContent = useMemo(() => getMarkListItemContent(mark), [mark])\n\n  const todoPriorityDotClass = itemContent.todo\n    ? itemContent.todo.priority === 'high'\n      ? 'bg-red-500'\n      : itemContent.todo.priority === 'low'\n        ? 'bg-green-500'\n        : 'bg-orange-500'\n    : ''\n\n  const handleCheckboxChange = useCallback(() => {\n    toggleMarkSelection(mark.id);\n  }, [mark.id, toggleMarkSelection]);\n\n  const handleRecordingAction = useCallback(async () => {\n    if (!sttModel) {\n      router.push(isMobile ? '/mobile/setting/pages/audio' : '/core/setting/audio')\n      return\n    }\n\n    if (!mark.url || isRetryingTranscription) {\n      return\n    }\n\n    try {\n      setIsRetryingTranscription(true)\n      const fileData = await readFile(mark.url, { baseDir: BaseDirectory.AppData })\n      const extension = mark.url.split('.').pop()?.toLowerCase()\n      const mimeType = extension === 'wav' ? 'audio/wav' :\n        extension === 'mp3' ? 'audio/mpeg' :\n        extension === 'm4a' || extension === 'mp4' ? 'audio/mp4' :\n        extension === 'ogg' ? 'audio/ogg' :\n        extension === 'webm' ? 'audio/webm' :\n        'audio/webm'\n      const buffer = fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength) as ArrayBuffer\n      const audioBlob = new Blob([buffer], { type: mimeType })\n      const transcription = await transcribeRecording(audioBlob)\n\n      if (!transcription.trim()) {\n        toast({\n          title: recordingT('error'),\n          description: recordingT('transcriptionEmpty'),\n          variant: 'destructive',\n        })\n        return\n      }\n\n      await updateMark({\n        ...mark,\n        desc: transcription.substring(0, 100),\n        content: transcription,\n      })\n      await fetchMarks()\n\n      toast({\n        title: recordingT('success'),\n        description: recordingT('retrySuccess'),\n      })\n    } catch (error) {\n      console.error('重新识别录音失败:', error)\n      toast({\n        title: recordingT('error'),\n        description: error instanceof Error ? error.message : recordingT('retryError'),\n        variant: 'destructive',\n      })\n    } finally {\n      setIsRetryingTranscription(false)\n    }\n  }, [fetchMarks, isMobile, isRetryingTranscription, mark, recordingT, router, sttModel])\n\n  if (variant === 'compact') {\n    return (\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {isMultiSelectMode && (\n          <div className=\"pr-1\">\n            <Checkbox\n              checked={selectedMarkIds.has(mark.id)}\n              onCheckedChange={handleCheckboxChange}\n            />\n          </div>\n        )}\n        <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n          {t(mark.type)}\n        </span>\n        {mark.type === 'todo' && itemContent.todo ? (\n          <span className={`size-2 shrink-0 rounded-full ${todoPriorityDotClass}`} />\n        ) : null}\n        <div className=\"min-w-0 flex-1\">\n          {mark.type === 'todo' ? (\n            <TodoEditTrigger mark={mark} className={`block truncate text-${recordTextSize} font-medium hover:underline`}>\n              {itemContent.title || itemContent.preview || t(mark.type)}\n            </TodoEditTrigger>\n          ) : (\n            <DetailViewer\n              mark={mark}\n              content={itemContent.title || itemContent.preview || t(mark.type)}\n              path={mark.type === 'scan' ? 'screenshot' : mark.type === 'image' ? 'image' : undefined}\n              className={`block truncate text-${recordTextSize} font-medium hover:underline`}\n            />\n          )}\n        </div>\n        {mark.type === 'recording' && mark.url ? (\n          <AudioPlayer audioPath={mark.url} compact />\n        ) : null}\n        <span className=\"shrink-0 text-xs text-zinc-500\">{dayjs(mark.createdAt).format('HH:mm')}</span>\n      </div>\n    )\n  }\n\n  if (variant === 'cards') {\n    const isImageCard = mark.type === 'image' || mark.type === 'scan'\n\n    return (\n      <div className=\"space-y-2.5\">\n        <div className=\"flex items-center gap-2 text-zinc-500\">\n          <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n            {t(mark.type)}\n          </span>\n          {mark.type === 'todo' && itemContent.todo ? (\n            <span className={`size-2 shrink-0 rounded-full ${todoPriorityDotClass}`} />\n          ) : null}\n          <span className=\"ml-auto text-xs\">{dayjs(mark.createdAt).format('MM-DD HH:mm')}</span>\n        </div>\n        {isImageCard && mark.url ? (\n          <div className=\"overflow-hidden rounded-md bg-zinc-100\">\n            <ImageViewer\n              url={mark.url}\n              path={mark.type === 'scan' ? 'screenshot' : 'image'}\n              imageClassName=\"h-auto max-h-56 w-full object-cover\"\n            />\n          </div>\n        ) : null}\n        <div className=\"space-y-1.5\">\n          {mark.type === 'todo' ? (\n            <TodoEditTrigger mark={mark} className={`block truncate text-${recordTextSize} font-semibold hover:underline`}>\n              {itemContent.title || itemContent.preview || t(mark.type)}\n            </TodoEditTrigger>\n          ) : (\n            <DetailViewer\n              mark={mark}\n              content={itemContent.title || itemContent.preview || t(mark.type)}\n              path={mark.type === 'scan' ? 'screenshot' : mark.type === 'image' ? 'image' : undefined}\n              className={`block truncate text-${recordTextSize} font-semibold hover:underline`}\n            />\n          )}\n          {!isImageCard && itemContent.preview ? (\n            <p className={`line-clamp-6 text-${recordTextSize} ${lineHeight} text-muted-foreground`}>\n              {itemContent.preview}\n            </p>\n          ) : null}\n          {!isImageCard && mark.type === 'link' && mark.url ? (\n            <a\n              href={mark.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className={`block truncate text-xs text-blue-600 hover:underline`}\n            >\n              {mark.url}\n            </a>\n          ) : null}\n          {!isImageCard && mark.type === 'todo' && itemContent.todo ? (\n            <div className=\"flex items-center gap-2 text-xs text-zinc-500\">\n              <div className=\"flex items-center gap-2\">\n                {itemContent.todo.completed ? <CheckSquare className=\"size-3.5 text-green-600\" /> : <Square className=\"size-3.5 text-zinc-400\" />}\n                <span>{itemContent.todo.completed ? todoT('completed') : todoT('uncompleted')}</span>\n              </div>\n            </div>\n          ) : null}\n          {!isImageCard && mark.type === 'recording' && mark.url ? (\n            <div className=\"pt-1\">\n              <AudioPlayer audioPath={mark.url} />\n            </div>\n          ) : null}\n        </div>\n      </div>\n    )\n  }\n\n  const renderContent = () => {\n    switch (mark.type) {\n    case 'scan':\n    return (\n        <div className={`flex-1 overflow-hidden text-${recordTextSize} ${lineHeight} pr-10 md:pr-2`}>\n          <div className=\"flex w-full items-center gap-2 text-zinc-500\">\n            <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n              {t(mark.type)}\n            </span>\n            <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n          </div>\n          <DetailViewer mark={mark} content={mark.desc || ''} path=\"screenshot\" />\n        </div>\n    )\n    case 'image':\n    return (\n        <div className={`flex-1 overflow-hidden text-${recordTextSize} ${lineHeight} pr-10 md:pr-2`}>\n          <div className=\"flex w-full items-center gap-2 text-zinc-500\">\n            <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n              {t(mark.type)}\n            </span>\n            {mark.url.includes('http') ? <ImageUp className=\"size-3 text-zinc-400\" /> : null}\n            <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n          </div>\n          <DetailViewer mark={mark} content={mark.desc || ''} path=\"image\" />\n        </div>\n    )\n    case 'link':\n    return (\n        <div className=\"flex-1 pr-10 md:pr-0\">\n          <div className={`flex w-full items-center gap-2 text-zinc-500 text-${recordTextSize} ${lineHeight}`}>\n            <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n              {t(mark.type)}\n            </span>\n            <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n          </div>\n          <DetailViewer mark={mark} content={mark.desc || ''} />\n          <div className=\"mt-1\">\n            <a \n              href={mark.url} \n              target=\"_blank\" \n              rel=\"noopener noreferrer\"\n              className={`text-${recordTextSize} text-blue-500 hover:underline truncate block`}\n            >\n              {mark.url}\n            </a>\n          </div>\n        </div>\n    )\n    case 'text':\n      return (\n          <div className=\"flex-1 pr-10 md:pr-0\">\n            <div className={`flex w-full items-center gap-2 text-zinc-500 text-${recordTextSize} ${lineHeight}`}>\n              <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n                {t(mark.type)}\n              </span>\n              <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n            </div>\n            <DetailViewer mark={mark} content={mark.content || ''} />\n          </div>\n      )\n    case 'recording':\n      return (\n          <div className=\"flex-1 pr-10 md:pr-0\">\n            <div className={`flex w-full items-center gap-2 text-zinc-500 text-${recordTextSize} ${lineHeight}`}>\n              <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n                {t(mark.type)}\n              </span>\n              {shouldShowRecordingAction && (\n                <button\n                  type=\"button\"\n                  className=\"shrink-0 text-zinc-500 transition-colors hover:text-zinc-800 disabled:cursor-not-allowed disabled:opacity-50\"\n                  onClick={handleRecordingAction}\n                  disabled={isRetryingTranscription}\n                  title={sttModel\n                    ? (isRetryingTranscription ? recordingT('retrying') : recordingT('retryTranscription'))\n                    : recordingT('configureModel')}\n                >\n                  {sttModel ? (\n                    <RefreshCw className={`size-3.5 ${isRetryingTranscription ? 'animate-spin' : ''}`} />\n                  ) : (\n                    <Settings2 className=\"size-3.5\" />\n                  )}\n                </button>\n              )}\n              <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n            </div>\n            <DetailViewer mark={mark} content={mark.content || ''} />\n            {mark.url && (\n              <div className=\"mt-2\">\n                <AudioPlayer audioPath={mark.url} />\n              </div>\n            )}\n          </div>\n      )\n    case 'file':\n      return (\n          <div className=\"flex-1 pr-10 md:pr-0\">\n            <div className={`flex w-full items-center gap-2 text-zinc-500 text-${recordTextSize} ${lineHeight}`}>\n              <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n                {t(mark.type)}\n              </span>\n              <span className={`ml-auto text-${recordTextSize}`}>{dayjs(mark.createdAt).fromNow()}</span>\n            </div>\n            <DetailViewer mark={mark} content={mark.content || ''} />\n            {mark.url && (\n              <div className=\"mt-1\">\n                <span className={`text-${recordTextSize}`}>\n                  {mark.desc}\n                </span>\n              </div>\n            )}\n          </div>\n      )\n    case 'todo':\n      return <TodoItemContent mark={mark} />\n    default:\n      return null\n    }\n  }\n\n  return (\n    <div className=\"flex p-2 items-start\">\n      {isMultiSelectMode && (\n        <div className=\"pr-2 flex items-start pt-1\">\n          <Checkbox\n            checked={selectedMarkIds.has(mark.id)}\n            onCheckedChange={handleCheckboxChange}\n          />\n        </div>\n      )}\n      <div className=\"flex-1 min-w-0\">\n        {renderContent()}\n      </div>\n      {(mark.type === 'scan' || mark.type === 'image') && (\n        <div className=\"bg-zinc-900 flex items-center justify-center ml-2\">\n          <ImageViewer url={mark.url} path={mark.type === 'scan' ? 'screenshot' : 'image'} />\n        </div>\n      )}\n    </div>\n  )\n})\nMarkWrapper.displayName = 'MarkWrapper'\n\nexport const MarkItem = React.memo(({mark, variant = 'list'}: {mark: Mark, variant?: MarkItemVariant}) => {\n  const t = useTranslations();\n  const isMobile = useIsMobile()\n  const {\n    marks,\n    fetchMarks,\n    trashState,\n    fetchAllTrashMarks,\n    isMultiSelectMode,\n    selectedMarkIds,\n    clearSelection,\n    highlightedMarkId,\n  } = useMarkStore()\n  const { tags, currentTagId, fetchTags, getCurrentTag } = useTagStore()\n\n  const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    if (isMultiSelectMode) {\n      e.preventDefault()\n      return\n    }\n\n    const markdownContent = markToMarkdown(mark);\n    e.dataTransfer.setData('text/plain', markdownContent);\n    e.dataTransfer.setData('application/json', JSON.stringify(mark));\n    e.dataTransfer.effectAllowed = 'copy';\n\n    // 添加拖拽时的视觉反馈\n    if (e.currentTarget instanceof HTMLElement) {\n      e.currentTarget.style.opacity = '0.5'\n    }\n  }, [isMultiSelectMode, mark]);\n\n  const handleDragEnd = useCallback((e: React.DragEvent<HTMLDivElement>) => {\n    if (e.currentTarget instanceof HTMLElement) {\n      e.currentTarget.style.opacity = '1'\n    }\n  }, []);\n\n  const handleDelMark = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    if (isMultiSelectMode && selectedMarkIds.size > 0) {\n      // 多选删除\n      const selectedMarks = Array.from(selectedMarkIds)\n      for (const markId of selectedMarks) {\n        await delMark(markId)\n      }\n      clearSelection()\n    } else {\n      // 单个删除\n      await delMark(mark.id)\n    }\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n  }, [isMultiSelectMode, selectedMarkIds, clearSelection, fetchMarks, fetchTags, getCurrentTag, mark.id])\n\n  const handleDelForever = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    if (isMultiSelectMode && selectedMarkIds.size > 0) {\n      // 多选永久删除\n      const selectedMarks = Array.from(selectedMarkIds)\n      for (const markId of selectedMarks) {\n        await delMarkForever(markId)\n      }\n      clearSelection()\n    } else {\n      // 单个永久删除\n      await delMarkForever(mark.id)\n    }\n    await fetchAllTrashMarks()\n  }, [isMultiSelectMode, selectedMarkIds, clearSelection, fetchAllTrashMarks, mark.id])\n\n  const handleRestore = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    await restoreMark(mark.id)\n    if (trashState) {\n      await fetchAllTrashMarks()\n    } else {\n      await fetchMarks()\n    }\n  }, [mark.id, trashState, fetchAllTrashMarks, fetchMarks])\n\n  const handleTransfer = useCallback(async (tagId: number, e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    if (isMultiSelectMode && selectedMarkIds.size > 0) {\n      // 多选转移 - 只处理选中的记录\n      const selectedMarks = Array.from(selectedMarkIds)\n      for (const markId of selectedMarks) {\n        // 获取完整的mark对象并更新tagId\n        const existingMark = marks.find((m: Mark) => m.id === markId)\n        if (existingMark) {\n          await updateMark({ ...existingMark, tagId })\n        }\n      }\n      clearSelection()\n    } else {\n      // 单个转移\n      await updateMark({ ...mark, tagId })\n    }\n    await fetchTags()\n    getCurrentTag()\n    fetchMarks()\n  }, [isMultiSelectMode, selectedMarkIds, clearSelection, marks, mark, fetchTags, getCurrentTag, fetchMarks])\n\n  const regenerateDesc = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    const desc = await fetchAiDesc(mark.content || '') || ''\n    await updateMark({ ...mark, desc })\n    fetchMarks()\n  }, [mark, fetchMarks])\n\n  const handelShowInFolder = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    const appDir = await appDataDir()\n    const path = mark.type === 'scan' ? 'screenshot' : 'image'\n    open(`${appDir}/${path}`)\n  }, [mark.type])\n\n  const handelShowInFile = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    const appDir = await appDataDir()\n    const path = mark.type === 'scan' ? 'screenshot' : 'image'\n    let filename = mark.url\n    if (mark.url.includes('http')) {\n      filename = mark.url.split('/').pop() || '';\n    }\n    open(`${appDir}/${path}/${filename}`)\n  }, [mark.type, mark.url])\n\n  const handleCopyLink = useCallback(async (e?: React.MouseEvent) => {\n    e?.stopPropagation()\n    await navigator.clipboard.writeText(mark.url)\n    toast({\n      title: t('record.mark.toolbar.copied')\n    })\n  }, [mark.url, t])\n\n  // Memoize filtered tags to prevent unnecessary re-renders\n  const filteredTags = useMemo(() =>\n    tags.filter(tag => tag.id !== currentTagId),\n    [tags, currentTagId]\n  )\n\n  const markCard = (\n    <div\n      data-mark-item=\"true\"\n      data-mark-id={mark.id}\n      className={`relative transition-colors ${\n        variant === 'cards'\n          ? 'rounded-md border border-border/70 bg-background p-2.5'\n          : variant === 'compact'\n            ? 'rounded-md border border-border/60 bg-background px-3 py-2'\n            : 'rounded-lg border border-border/60 bg-background'\n      } ${highlightedMarkId === mark.id ? 'record-search-highlight border-amber-400/80 bg-amber-50/80 dark:border-amber-400/70 dark:bg-amber-500/10' : ''} ${isMobile ? 'cursor-default active:bg-accent/40' : 'cursor-move hover:bg-accent/50'}`}\n      draggable={!isMultiSelectMode && !isMobile}\n      onDragStart={handleDragStart}\n      onDragEnd={handleDragEnd}\n    >\n      <MarkWrapper mark={mark} variant={variant} />\n      <div className=\"absolute top-2 right-2\">\n        <MarkMobileActions\n          mark={mark}\n          tags={tags}\n          currentTagId={currentTagId}\n          trashState={trashState}\n          isMultiSelectMode={isMultiSelectMode}\n          selectedMarkIds={selectedMarkIds}\n          onTransfer={handleTransfer}\n          onCopyLink={handleCopyLink}\n          onRegenerateDesc={regenerateDesc}\n          onShowInFolder={handelShowInFolder}\n          onShowInFile={handelShowInFile}\n          onRestore={handleRestore}\n          onDelete={handleDelMark}\n          onDeleteForever={handleDelForever}\n        />\n      </div>\n    </div>\n  )\n\n  if (isMobile) {\n    return markCard\n  }\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger asChild>\n        {markCard}\n      </ContextMenuTrigger>\n      <ContextMenuContent>\n        {\n          trashState ? null :\n          <ContextMenuSub>\n            <ContextMenuSubTrigger inset menuType=\"record\">\n              {isMultiSelectMode && selectedMarkIds.size > 0\n                ? t('record.mark.toolbar.moveSelectedTags', { count: selectedMarkIds.size })\n                : t('record.mark.toolbar.moveTag')\n              }\n            </ContextMenuSubTrigger>\n            <ContextMenuSubContent>\n              {\n                filteredTags.map((tag) => (\n                  <ContextMenuItem\n                    disabled={tag.id === currentTagId}\n                    key={tag.id}\n                    onClick={() => handleTransfer(tag.id)}\n                    menuType=\"record\"\n                  >\n                    {tag.name}\n                  </ContextMenuItem>\n                ))\n              }\n            </ContextMenuSubContent>\n          </ContextMenuSub>\n        }\n        <ContextMenuItem inset disabled={isMultiSelectMode || true} menuType=\"record\">\n          {t('record.mark.toolbar.convertTo', { type: mark.type === 'scan' ? t('record.mark.type.image') : t('record.mark.type.screenshot') })}\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={isMultiSelectMode || !mark.url} onClick={handleCopyLink} menuType=\"record\">\n          {t('record.mark.toolbar.copyLink')}\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={isMultiSelectMode || mark.type === 'text'} onClick={regenerateDesc} menuType=\"record\">\n          {t('record.mark.toolbar.regenerateDesc')}\n        </ContextMenuItem>\n        <ContextMenuSeparator />\n        <ContextMenuItem inset disabled={isMultiSelectMode || mark.type === 'text'} onClick={handelShowInFolder} menuType=\"record\">\n          {t('record.mark.toolbar.viewFolder')}\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={isMultiSelectMode || mark.type === 'text'} onClick={handelShowInFile} menuType=\"record\">\n          {t('record.mark.toolbar.viewFile')}\n        </ContextMenuItem>\n        {\n          trashState ? \n          <>\n            <ContextMenuItem inset disabled={isMultiSelectMode} onClick={handleRestore} menuType=\"record\">\n              {t('record.mark.toolbar.restore')}\n            </ContextMenuItem>\n            <ContextMenuItem inset onClick={handleDelForever} menuType=\"record\">\n              <span className=\"text-red-900\">\n                {isMultiSelectMode && selectedMarkIds.size > 0 \n                  ? t('record.mark.toolbar.deleteSelectedForever', { count: selectedMarkIds.size })\n                  : t('record.mark.toolbar.deleteForever')\n                }\n              </span>\n            </ContextMenuItem>\n          </> :\n          <ContextMenuItem inset onClick={handleDelMark} menuType=\"record\">\n            <span className=\"text-red-900\">\n              {isMultiSelectMode && selectedMarkIds.size > 0 \n                ? t('record.mark.toolbar.deleteSelected', { count: selectedMarkIds.size })\n                : t('record.mark.toolbar.delete')\n              }\n            </span>\n          </ContextMenuItem>\n        }\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n})\nMarkItem.displayName = 'MarkItem'\n"
  },
  {
    "path": "src/app/core/main/mark/mark-list-card-view.tsx",
    "content": "'use client'\n\nimport type { Mark } from \"@/db/marks\"\nimport { MarkItem } from \"./mark-item\"\n\nexport function MarkListCardView({ marks }: { marks: Mark[] }) {\n  return (\n    <div\n      className=\"columns-auto gap-3 px-3 py-3\"\n      style={{ columnWidth: '15rem' }}\n    >\n      {marks.map((mark) => (\n        <div key={mark.id} className=\"mb-3 break-inside-avoid\">\n          <MarkItem mark={mark} variant=\"cards\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-list-compact-view.tsx",
    "content": "'use client'\n\nimport type { Mark } from \"@/db/marks\"\nimport { MarkItem } from \"./mark-item\"\n\nexport function MarkListCompactView({ marks }: { marks: Mark[] }) {\n  return (\n    <div className=\"space-y-1.5 px-2 py-2\">\n      {marks.map((mark) => (\n        <MarkItem key={mark.id} mark={mark} variant=\"compact\" />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-list-default-view.tsx",
    "content": "'use client'\n\nimport type { Mark } from \"@/db/marks\"\nimport { MarkItem } from \"./mark-item\"\n\nexport function MarkListDefaultView({ marks }: { marks: Mark[] }) {\n  return (\n    <div className=\"space-y-2 px-2 py-2\">\n      {marks.map((mark) => (\n        <MarkItem key={mark.id} mark={mark} variant=\"list\" />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-list-item-content.tsx",
    "content": "import type { Mark } from \"@/db/marks\"\nimport type { Priority } from \"./todo-form\"\n\nexport type ParsedTodoMark = {\n  title: string\n  description: string\n  completed: boolean\n  priority: Priority\n}\n\nexport type MarkListItemContent = {\n  title: string\n  preview: string\n  imageUrl?: string\n  linkUrl?: string\n  todo?: ParsedTodoMark\n}\n\nconst DEFAULT_TODO: ParsedTodoMark = {\n  title: '',\n  description: '',\n  completed: false,\n  priority: 'medium',\n}\n\nfunction compactText(value?: string) {\n  return value?.replace(/\\s+/g, ' ').trim() || ''\n}\n\nfunction splitTitleAndPreview(value?: string) {\n  const text = compactText(value)\n  if (!text) {\n    return { title: '', preview: '' }\n  }\n\n  const title = text.slice(0, 48).trim()\n  const preview = text.length > 48 ? text.slice(48).trim() : text\n\n  return { title, preview }\n}\n\nexport function parseTodoMarkContent(mark: Mark): ParsedTodoMark {\n  try {\n    const parsed = JSON.parse(mark.content || '{}')\n    return {\n      title: compactText(parsed.title) || compactText(mark.desc),\n      description: compactText(parsed.description),\n      completed: Boolean(parsed.completed),\n      priority: parsed.priority || 'medium',\n    }\n  } catch {\n    return {\n      ...DEFAULT_TODO,\n      title: compactText(mark.desc),\n    }\n  }\n}\n\nexport function getMarkListItemContent(mark: Mark): MarkListItemContent {\n  switch (mark.type) {\n  case 'text': {\n    const fallback = compactText(mark.desc)\n    const { title, preview } = splitTitleAndPreview(mark.content || mark.desc)\n    return {\n      title: title || fallback,\n      preview: preview || title || fallback,\n    }\n  }\n  case 'recording': {\n    const desc = compactText(mark.desc)\n    const { title, preview } = splitTitleAndPreview(mark.content)\n    return {\n      title: desc || title,\n      preview: preview || title || desc,\n    }\n  }\n  case 'scan':\n  case 'image': {\n    const title = compactText(mark.desc) || compactText(mark.content)\n    return {\n      title,\n      preview: compactText(mark.content) || title,\n      imageUrl: mark.url,\n    }\n  }\n  case 'link': {\n    const title = compactText(mark.desc) || compactText(mark.url)\n    return {\n      title,\n      preview: compactText(mark.url),\n      linkUrl: mark.url,\n    }\n  }\n  case 'file': {\n    const desc = compactText(mark.desc)\n    const { title, preview } = splitTitleAndPreview(mark.content)\n    return {\n      title: desc || title || compactText(mark.url),\n      preview: preview || compactText(mark.url) || desc || title,\n    }\n  }\n  case 'todo': {\n    const todo = parseTodoMarkContent(mark)\n    return {\n      title: todo.title,\n      preview: todo.description,\n      todo,\n    }\n  }\n  default:\n    return {\n      title: compactText(mark.desc) || compactText(mark.content) || compactText(mark.url),\n      preview: compactText(mark.content) || compactText(mark.desc) || compactText(mark.url),\n    }\n  }\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-list.tsx",
    "content": "'use client'\n\nimport React from \"react\"\nimport { useTranslations } from \"next-intl\";\nimport type { Mark } from \"@/db/marks\";\nimport { Badge } from \"@/components/ui/badge\";\nimport useMarkStore from \"@/stores/mark\";\nimport { MarkLoading } from \"./mark-loading\";\nimport MarkEmpty from \"./mark-empty\";\nimport { buildRecordFilterSummary, filterMarks } from \"./mark-filters.mjs\";\nimport { MarkListDefaultView } from \"./mark-list-default-view\";\nimport { MarkListCompactView } from \"./mark-list-compact-view\";\nimport { MarkListCardView } from \"./mark-list-card-view\";\n\nexport const MarkList = React.memo(function MarkList() {\n  const t = useTranslations('record.mark.list')\n  const {\n    marks,\n    queues,\n    recordFilters,\n    recordViewMode,\n    hasActiveRecordFilters,\n    setVisibleMarkIds,\n  } = useMarkStore()\n\n  const filteredMarks = React.useMemo(() => (\n    filterMarks(marks, recordFilters)\n  ), [marks, recordFilters])\n\n  const filterSummary = React.useMemo(() => buildRecordFilterSummary(recordFilters), [recordFilters])\n\n  React.useEffect(() => {\n    setVisibleMarkIds(filteredMarks.map((mark: Mark) => mark.id))\n    return () => setVisibleMarkIds([])\n  }, [filteredMarks, setVisibleMarkIds])\n\n  const view = (() => {\n    switch (recordViewMode) {\n    case 'compact':\n      return <MarkListCompactView marks={filteredMarks} />\n    case 'cards':\n      return <MarkListCardView marks={filteredMarks} />\n    case 'list':\n    default:\n      return <MarkListDefaultView marks={filteredMarks} />\n    }\n  })()\n\n  return (\n    <div className=\"flex-1 overflow-y-auto\">\n      <div className=\"px-0\">\n        <div>\n          {hasActiveRecordFilters() ? (\n            <div className=\"border-b bg-muted/20 px-3 py-2\">\n              <div className=\"flex flex-wrap items-center gap-2\">\n                <Badge variant=\"secondary\" className=\"rounded-full px-2 py-0 text-[11px]\">\n                  {t('filteredLabel', { count: filteredMarks.length })}\n                </Badge>\n                {filterSummary.search ? (\n                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0 text-[11px] font-normal\">\n                    {t('searchChip', { value: filterSummary.search })}\n                  </Badge>\n                ) : null}\n                {filterSummary.timePreset !== 'all' ? (\n                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0 text-[11px] font-normal\">\n                    {t(`time.${filterSummary.timePreset}`)}\n                  </Badge>\n                ) : null}\n                {filterSummary.typeCount > 0 ? (\n                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0 text-[11px] font-normal\">\n                    {t('filteredByType', { count: filterSummary.typeCount })}\n                  </Badge>\n                ) : null}\n                {filterSummary.hasTag ? (\n                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0 text-[11px] font-normal\">\n                    {t('filteredByTag')}\n                  </Badge>\n                ) : null}\n              </div>\n            </div>\n          ) : null}\n          {\n            queues.map(mark => {\n              return (\n                <MarkLoading key={mark.queueId} mark={mark} />\n              )\n            })\n          }\n          {\n            filteredMarks.length ? (\n              view\n            ) : hasActiveRecordFilters() ? (\n              <div className=\"flex flex-col justify-center items-center flex-1 w-full pt-32 text-center\">\n                <p className=\"text-sm text-zinc-500\">{t('emptyFiltered')}</p>\n                <p className=\"mt-1 text-xs text-zinc-400\">{t('emptyFilteredHint')}</p>\n              </div>\n            ) : <MarkEmpty />\n          }\n        </div>\n      </div>\n    </div>\n  )\n})\n"
  },
  {
    "path": "src/app/core/main/mark/mark-loading.tsx",
    "content": "'use client'\nimport { MarkQueue } from \"@/stores/mark\";\nimport { LoaderCircle } from \"lucide-react\";\nimport { useTranslations } from \"next-intl\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport function MarkLoading({mark}: {mark: MarkQueue}){\n  const [timeNow, setTimeNow] = useState(Date.now())\n  const timer = useRef<NodeJS.Timeout>()\n  const t = useTranslations('record.mark.type');\n\n  useEffect(() => {\n    // 挂载时执行的操作\n    timer.current = setInterval(() => {\n      setTimeNow(Date.now())\n    }, 1000);\n    return () => {\n      clearInterval(timer.current);\n    };\n  }, []);\n\n  return (\n    <div className=\"flex justify-between px-2 items-center gap-1 py-2 text-xs border-b text-zinc-500\">\n      <div className=\"flex gap-1\">\n        <LoaderCircle className=\"animate-spin size-4\" />\n        <span className=\"flex items-center gap-1 bg-zinc-500 text-white px-1 rounded\">\n          {t(mark.type)}\n        </span>\n        <span>{mark.progress}...</span>\n      </div>\n      <time className=\"text-zinc-400\" suppressHydrationWarning={true}>{Math.round((timeNow - mark.startTime) / 1000)}s</time>\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/core/main/mark/mark-mobile-actions.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { MoreVertical, FolderOpen, File, Link2, RefreshCw, Trash2, RotateCcw, XCircle } from 'lucide-react'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Button } from \"@/components/ui/button\"\nimport { Mark } from '@/db/marks'\nimport { Tag } from '@/db/tags'\nimport { useIsMobile } from '@/hooks/use-mobile'\n\ninterface MarkMobileActionsProps {\n  mark: Mark\n  tags: Tag[]\n  currentTagId: number | null\n  trashState: boolean\n  isMultiSelectMode: boolean\n  selectedMarkIds: Set<number>\n  onTransfer: (tagId: number, e?: React.MouseEvent) => void\n  onCopyLink: (e?: React.MouseEvent) => void\n  onRegenerateDesc: (e?: React.MouseEvent) => void\n  onShowInFolder: (e?: React.MouseEvent) => void\n  onShowInFile: (e?: React.MouseEvent) => void\n  onRestore: (e?: React.MouseEvent) => void\n  onDelete: (e?: React.MouseEvent) => void\n  onDeleteForever: (e?: React.MouseEvent) => void\n}\n\nexport function MarkMobileActions({ \n  mark,\n  tags,\n  currentTagId,\n  trashState,\n  isMultiSelectMode,\n  selectedMarkIds,\n  onTransfer,\n  onCopyLink,\n  onRegenerateDesc,\n  onShowInFolder,\n  onShowInFile,\n  onRestore,\n  onDelete,\n  onDeleteForever\n}: MarkMobileActionsProps) {\n  const t = useTranslations()\n  const isMobile = useIsMobile()\n\n  // 只在移动端显示\n  if (!isMobile) return null\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>\n        <Button \n          variant=\"ghost\" \n          size=\"icon\" \n          className=\"h-11 w-11 shrink-0\"\n        >\n          <MoreVertical className=\"h-4 w-4\" />\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-48\">\n        {!trashState && (\n          <DropdownMenuSub>\n            <DropdownMenuSubTrigger>\n              {isMultiSelectMode && selectedMarkIds.size > 0 \n                ? t('record.mark.toolbar.moveSelectedTags', { count: selectedMarkIds.size })\n                : t('record.mark.toolbar.moveTag')\n              }\n            </DropdownMenuSubTrigger>\n            <DropdownMenuSubContent>\n              {tags.map((tag) => (\n                <DropdownMenuItem \n                  key={tag.id}\n                  disabled={tag.id === currentTagId}\n                  onClick={(e) => onTransfer(tag.id, e)}\n                >\n                  {tag.name}\n                </DropdownMenuItem>\n              ))}\n            </DropdownMenuSubContent>\n          </DropdownMenuSub>\n        )}\n        \n        <DropdownMenuItem \n          disabled={isMultiSelectMode || !mark.url}\n          onClick={(e) => onCopyLink(e)}\n        >\n          <Link2 className=\"mr-2 h-4 w-4\" />\n          {t('record.mark.toolbar.copyLink')}\n        </DropdownMenuItem>\n        \n        <DropdownMenuItem \n          disabled={isMultiSelectMode || mark.type === 'text'}\n          onClick={(e) => onRegenerateDesc(e)}\n        >\n          <RefreshCw className=\"mr-2 h-4 w-4\" />\n          {t('record.mark.toolbar.regenerateDesc')}\n        </DropdownMenuItem>\n        \n        <DropdownMenuSeparator />\n        \n        <DropdownMenuItem \n          disabled={isMultiSelectMode || mark.type === 'text'}\n          onClick={(e) => onShowInFolder(e)}\n        >\n          <FolderOpen className=\"mr-2 h-4 w-4\" />\n          {t('record.mark.toolbar.viewFolder')}\n        </DropdownMenuItem>\n        \n        <DropdownMenuItem \n          disabled={isMultiSelectMode || mark.type === 'text'}\n          onClick={(e) => onShowInFile(e)}\n        >\n          <File className=\"mr-2 h-4 w-4\" />\n          {t('record.mark.toolbar.viewFile')}\n        </DropdownMenuItem>\n        \n        <DropdownMenuSeparator />\n        \n        {trashState ? (\n          <>\n            <DropdownMenuItem \n              disabled={isMultiSelectMode}\n              onClick={(e) => onRestore(e)}\n            >\n              <RotateCcw className=\"mr-2 h-4 w-4\" />\n              {t('record.mark.toolbar.restore')}\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={(e) => onDeleteForever(e)}>\n              <XCircle className=\"mr-2 h-4 w-4 text-red-600\" />\n              <span className=\"text-red-600\">\n                {isMultiSelectMode && selectedMarkIds.size > 0 \n                  ? t('record.mark.toolbar.deleteSelectedForever', { count: selectedMarkIds.size })\n                  : t('record.mark.toolbar.deleteForever')\n                }\n              </span>\n            </DropdownMenuItem>\n          </>\n        ) : (\n          <DropdownMenuItem onClick={(e) => onDelete(e)}>\n            <Trash2 className=\"mr-2 h-4 w-4 text-red-600\" />\n            <span className=\"text-red-600\">\n              {isMultiSelectMode && selectedMarkIds.size > 0 \n                ? t('record.mark.toolbar.deleteSelected', { count: selectedMarkIds.size })\n                : t('record.mark.toolbar.delete')\n              }\n            </span>\n          </DropdownMenuItem>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-toolbar.tsx",
    "content": "'use client'\n\nimport { ListChecks, SquareCheckBig, XCircle } from \"lucide-react\";\nimport { useTranslations } from 'next-intl';\nimport useMarkStore from \"@/stores/mark\";\nimport { MarkViewModeToggle } from \"./mark-view-mode-toggle\";\nimport { BottomBarIconButton } from \"@/components/bottom-bar-icon-button\";\n\nexport function MarkToolbar() {\n  const { \n    marks, \n    visibleMarkIds,\n    isMultiSelectMode, \n    setMultiSelectMode, \n    selectedMarkIds, \n    setSelectedMarkIds,\n    selectAll, \n    clearSelection,\n    recordViewMode,\n    setRecordViewMode,\n  } = useMarkStore()\n  const t = useTranslations('record.mark.toolbar')\n\n  const handleToggleMultiSelect = () => {\n    setMultiSelectMode(!isMultiSelectMode)\n  }\n\n  const handleSelectAll = () => {\n    if (isAllSelected) {\n      setSelectedMarkIds(new Set())\n    } else {\n      selectAll()\n    }\n  }\n\n  const visibleCount = visibleMarkIds.length > 0 ? visibleMarkIds.length : marks.length\n  const isAllSelected = visibleCount > 0 && selectedMarkIds.size === visibleCount\n\n  if (marks.length === 0) {\n    return null\n  }\n\n  return (\n    <div className=\"flex h-6 items-center justify-between overflow-hidden border-t border-border bg-background px-2 text-xs text-muted-foreground\">\n      <div className=\"min-w-0\">\n        {isMultiSelectMode ? (\n          <span className=\"text-xs text-muted-foreground\">\n            {t('selectedCount', { count: selectedMarkIds.size })}\n          </span>\n        ) : (\n          <span className=\"text-xs text-muted-foreground\">\n            {t('visibleCount', { count: visibleCount })}\n          </span>\n        )}\n      </div>\n      <div className=\"flex items-center gap-1\">\n        {isMultiSelectMode ? (\n          <>\n            <BottomBarIconButton\n              icon={<ListChecks className=\"size-3\" />}\n              label={isAllSelected ? t('deselectAll') : t('selectAll')}\n              onClick={handleSelectAll}\n            />\n            <BottomBarIconButton\n              icon={<XCircle className=\"size-3\" />}\n              label={t('exitMultiSelect')}\n              onClick={clearSelection}\n            />\n          </>\n        ) : (\n          <>\n            <MarkViewModeToggle value={recordViewMode} onChange={setRecordViewMode} />\n            <BottomBarIconButton\n              icon={<SquareCheckBig className=\"size-3\" />}\n              label={t('multiSelect')}\n              onClick={handleToggleMultiSelect}\n            />\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-type-meta.ts",
    "content": "import type { Mark } from \"@/db/marks\"\nimport { cn } from \"@/lib/utils\"\n\nexport const MARK_TYPE_OPTIONS: Mark[\"type\"][] = ['text', 'recording', 'scan', 'image', 'link', 'file', 'todo']\n\ntype MarkTypeTone = {\n  list: string\n  chipActive: string\n  chipInactive: string\n}\n\nconst MARK_TYPE_TONES: Record<Mark[\"type\"], MarkTypeTone> = {\n  text: {\n    list: \"border-lime-300/80 bg-lime-100 text-lime-900\",\n    chipActive: \"border-lime-300 bg-lime-50 text-lime-900 hover:bg-lime-100 dark:border-lime-500/60 dark:bg-lime-500/18 dark:text-lime-200 dark:hover:bg-lime-500/24\",\n    chipInactive: \"border-lime-200/70 bg-lime-50/40 text-lime-800/80 hover:bg-lime-50 dark:border-lime-500/35 dark:bg-lime-500/10 dark:text-lime-200/90 dark:hover:bg-lime-500/18\",\n  },\n  recording: {\n    list: \"border-rose-300/80 bg-rose-100 text-rose-900\",\n    chipActive: \"border-rose-300 bg-rose-50 text-rose-900 hover:bg-rose-100 dark:border-rose-500/60 dark:bg-rose-500/18 dark:text-rose-200 dark:hover:bg-rose-500/24\",\n    chipInactive: \"border-rose-200/70 bg-rose-50/40 text-rose-800/80 hover:bg-rose-50 dark:border-rose-500/35 dark:bg-rose-500/10 dark:text-rose-200/90 dark:hover:bg-rose-500/18\",\n  },\n  scan: {\n    list: \"border-cyan-300/80 bg-cyan-100 text-cyan-900\",\n    chipActive: \"border-cyan-300 bg-cyan-50 text-cyan-900 hover:bg-cyan-100 dark:border-cyan-500/60 dark:bg-cyan-500/18 dark:text-cyan-200 dark:hover:bg-cyan-500/24\",\n    chipInactive: \"border-cyan-200/70 bg-cyan-50/40 text-cyan-800/80 hover:bg-cyan-50 dark:border-cyan-500/35 dark:bg-cyan-500/10 dark:text-cyan-200/90 dark:hover:bg-cyan-500/18\",\n  },\n  image: {\n    list: \"border-fuchsia-300/80 bg-fuchsia-100 text-fuchsia-900\",\n    chipActive: \"border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 hover:bg-fuchsia-100 dark:border-fuchsia-500/60 dark:bg-fuchsia-500/18 dark:text-fuchsia-200 dark:hover:bg-fuchsia-500/24\",\n    chipInactive: \"border-fuchsia-200/70 bg-fuchsia-50/40 text-fuchsia-800/80 hover:bg-fuchsia-50 dark:border-fuchsia-500/35 dark:bg-fuchsia-500/10 dark:text-fuchsia-200/90 dark:hover:bg-fuchsia-500/18\",\n  },\n  link: {\n    list: \"border-blue-300/80 bg-blue-100 text-blue-900\",\n    chipActive: \"border-blue-300 bg-blue-50 text-blue-900 hover:bg-blue-100 dark:border-blue-500/60 dark:bg-blue-500/18 dark:text-blue-200 dark:hover:bg-blue-500/24\",\n    chipInactive: \"border-blue-200/70 bg-blue-50/40 text-blue-800/80 hover:bg-blue-50 dark:border-blue-500/35 dark:bg-blue-500/10 dark:text-blue-200/90 dark:hover:bg-blue-500/18\",\n  },\n  file: {\n    list: \"border-amber-300/80 bg-amber-100 text-amber-900\",\n    chipActive: \"border-amber-300 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/60 dark:bg-amber-500/18 dark:text-amber-200 dark:hover:bg-amber-500/24\",\n    chipInactive: \"border-amber-200/70 bg-amber-50/40 text-amber-800/80 hover:bg-amber-50 dark:border-amber-500/35 dark:bg-amber-500/10 dark:text-amber-200/90 dark:hover:bg-amber-500/18\",\n  },\n  todo: {\n    list: \"border-slate-300/80 bg-slate-200 text-slate-900\",\n    chipActive: \"border-slate-300 bg-slate-100 text-slate-900 hover:bg-slate-200 dark:border-slate-400/60 dark:bg-slate-400/20 dark:text-slate-100 dark:hover:bg-slate-400/28\",\n    chipInactive: \"border-slate-200/80 bg-slate-50/70 text-slate-700 hover:bg-slate-100 dark:border-slate-400/35 dark:bg-slate-400/12 dark:text-slate-100/90 dark:hover:bg-slate-400/20\",\n  },\n}\n\nexport function getMarkTypeChipClasses(type: Mark[\"type\"], active: boolean) {\n  return active ? MARK_TYPE_TONES[type].chipActive : MARK_TYPE_TONES[type].chipInactive\n}\n\nexport function getMarkTypeListBadgeClasses(type: Mark[\"type\"], textSize?: string) {\n  return cn(\n    \"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-medium\",\n    MARK_TYPE_TONES[type].list,\n    textSize ? `text-${textSize}` : \"text-xs\"\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-view-mode-toggle.tsx",
    "content": "'use client'\n\nimport { LayoutGrid, Rows3, StretchHorizontal } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport type { RecordViewMode } from \"@/stores/mark\"\nimport { cn } from \"@/lib/utils\"\nimport { BottomBarIconButton } from \"@/components/bottom-bar-icon-button\"\n\ntype MarkViewModeToggleProps = {\n  value: RecordViewMode\n  onChange: (mode: RecordViewMode) => void\n}\n\nconst VIEW_MODE_ITEMS: Array<{\n  mode: RecordViewMode\n  icon: typeof Rows3\n}> = [\n  { mode: 'list', icon: Rows3 },\n  { mode: 'compact', icon: StretchHorizontal },\n  { mode: 'cards', icon: LayoutGrid },\n]\n\nexport function MarkViewModeToggle({ value, onChange }: MarkViewModeToggleProps) {\n  const t = useTranslations('record.mark.toolbar.view')\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      {VIEW_MODE_ITEMS.map(({ mode, icon: Icon }) => (\n        <BottomBarIconButton\n          key={mode}\n          icon={<Icon className=\"size-3\" />}\n          label={t(mode)}\n          onClick={() => onChange(mode)}\n          active={value === mode}\n          className={cn(value === mode && \"text-foreground\")}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-view-mode.mjs",
    "content": "export const RECORD_VIEW_MODES = [\"list\", \"compact\", \"cards\"]\n\nexport function normalizeRecordViewMode(value) {\n  return RECORD_VIEW_MODES.includes(value) ? value : \"list\"\n}\n"
  },
  {
    "path": "src/app/core/main/mark/mark-view-mode.spec.mjs",
    "content": "import test from \"node:test\"\nimport assert from \"node:assert/strict\"\nimport { normalizeRecordViewMode } from \"./mark-view-mode.mjs\"\n\ntest(\"normalizes persisted record view mode\", () => {\n  assert.equal(normalizeRecordViewMode(\"list\"), \"list\")\n  assert.equal(normalizeRecordViewMode(\"compact\"), \"compact\")\n  assert.equal(normalizeRecordViewMode(\"cards\"), \"cards\")\n  assert.equal(normalizeRecordViewMode(\"table\"), \"list\")\n  assert.equal(normalizeRecordViewMode(undefined), \"list\")\n})\n"
  },
  {
    "path": "src/app/core/main/mark/organize-notes.tsx",
    "content": "\"use client\"\nimport useSettingStore, { GenTemplate, GenTemplateRange } from \"@/stores/setting\"\nimport useMarkStore from \"@/stores/mark\"\nimport useArticleStore from \"@/stores/article\"\nimport useTagStore from \"@/stores/tag\"\nimport { fetchAiStream } from \"@/lib/ai/chat\"\nimport { convertImage } from \"@/lib/utils\"\nimport {\n  AlertDialog,\n  AlertDialogContent,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport {\n  Tabs,\n  TabsList,\n  TabsTrigger,\n} from \"@/components/ui/tabs\"\nimport { useCallback, useEffect, useMemo, useImperativeHandle, forwardRef, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { Label } from \"@/components/ui/label\"\nimport { useSidebarStore } from \"@/stores/sidebar\"\nimport { useRouter } from \"next/navigation\"\nimport dayjs, { Dayjs } from \"dayjs\"\nimport { ScrollArea } from \"@/components/ui/scroll-area\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { useTranslations } from \"next-intl\"\nimport { writeTextFile, exists } from \"@tauri-apps/plugin-fs\"\nimport { getFilePathOptions, getWorkspacePath } from \"@/lib/workspace\"\nimport { toast } from \"@/hooks/use-toast\"\nimport emitter from \"@/lib/emitter\"\nimport { shouldEmitOrganizeOnboardingComplete } from \"./organize-onboarding\"\n\ninterface OrganizeNotesProps {\n  inputValue?: string;\n}\n\nexport const OrganizeNotes = forwardRef<{ openOrganize: () => void }, OrganizeNotesProps>(({ inputValue }, ref) => {\n  const [open, setOpen] = useState(false)\n  const { primaryModel } = useSettingStore()\n  const { fetchMarks, marks } = useMarkStore()\n  const { currentTag } = useTagStore()\n  const { setActiveFilePath, loadFileTree, readArticle, setCurrentArticle, setSkipSyncOnSave, setAiGeneratingFilePath, setAiTerminateFn } = useArticleStore()\n  const { setLeftSidebarTab } = useSidebarStore()\n  const router = useRouter()\n  const [tab, setTab] = useState('0')\n  const [genTemplate, setGenTemplate] = useState<GenTemplate[]>([])\n  const [loading, setLoading] = useState(false)\n  const abortControllerRef = useRef<AbortController | null>(null)\n  const [isRemoveThinking, setIsRemoveThinking] = useState(true)\n  const t = useTranslations('record.chat.note')\n  const tMark = useTranslations('record.mark')\n\n  async function initGenTemplates() {\n    const store = await Store.load('store.json')\n    const template = await store.get<GenTemplate[]>('templateList') || []\n    setGenTemplate(template)\n  }\n\n  // 使用 useMemo 优化过滤的记录\n  const marksByRange = useMemo(() => {\n    const range = genTemplate.find(item => item.id === tab)?.range\n    let subtractDate: Dayjs\n    switch (range) {\n      case GenTemplateRange.All:\n        subtractDate = dayjs().subtract(99, 'year')\n        break\n      case GenTemplateRange.Today:\n        subtractDate = dayjs().subtract(1, 'day')\n        break\n      case GenTemplateRange.Week:\n        subtractDate = dayjs().subtract(1, 'week')\n        break\n      case GenTemplateRange.Month:\n        subtractDate = dayjs().subtract(1, 'month')\n        break\n      case GenTemplateRange.ThreeMonth:\n        subtractDate = dayjs().subtract(3, 'month')\n        break\n      case GenTemplateRange.Year:\n        subtractDate = dayjs().subtract(1, 'year')\n        break\n      default:\n        subtractDate = dayjs().subtract(99, 'year')\n        break\n    }\n    return marks.filter(item => dayjs(item.createdAt).isAfter(subtractDate))\n  }, [marks, genTemplate, tab])\n\n  // 使用 useMemo 优化分类记录\n  const categorizedMarks = useMemo(() => {\n    return {\n      scanMarks: marksByRange.filter(item => item.type === 'scan'),\n      textMarks: marksByRange.filter(item => item.type === 'text'),\n      imageMarks: marksByRange.filter(item => item.type === 'image'),\n      linkMarks: marksByRange.filter(item => item.type === 'link'),\n      fileMarks: marksByRange.filter(item => item.type === 'file')\n    }\n  }, [marksByRange])\n\n  // 使用 useMemo 优化选中的模板\n  const selectedTemplate = useMemo(() => {\n    return genTemplate.find(item => item.id === tab)\n  }, [genTemplate, tab])\n\n  const terminateGeneration = useCallback(() => {\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n      abortControllerRef.current = null\n      setLoading(false)\n    }\n  }, [])\n\n  const openOrganize = useCallback(() => {\n    setOpen(true)\n    initGenTemplates()\n  }, [])\n\n  const handleOrganize = useCallback(async () => {\n    setOpen(false)\n    if (!primaryModel) return\n\n    setLoading(true)\n\n    // Prepare file path outside try block for access in finally\n    const timestamp = new Date().getTime()\n    const fileName = `整理笔记_${timestamp}.md`\n    const filePath = fileName\n\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(filePath)\n\n      if (workspace.isCustom) {\n        await writeTextFile(pathOptions.path, '')\n      } else {\n        await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n      }\n\n      await loadFileTree()\n      await setActiveFilePath(filePath)\n\n      // Switch to files tab in sidebar\n      await setLeftSidebarTab('files')\n\n      await new Promise(resolve => setTimeout(resolve, 500))\n\n      await fetchMarks()\n\n      // Get latest marks from store after fetch\n      const latestMarks = useMarkStore.getState().marks\n\n      // Calculate marksByRange with latest marks\n      const range = selectedTemplate?.range\n      let subtractDate: Dayjs\n      switch (range) {\n        case GenTemplateRange.All:\n          subtractDate = dayjs().subtract(99, 'year')\n          break\n        case GenTemplateRange.Today:\n          subtractDate = dayjs().subtract(1, 'day')\n          break\n        case GenTemplateRange.Week:\n          subtractDate = dayjs().subtract(1, 'week')\n          break\n        case GenTemplateRange.Month:\n          subtractDate = dayjs().subtract(1, 'month')\n          break\n        case GenTemplateRange.ThreeMonth:\n          subtractDate = dayjs().subtract(3, 'month')\n          break\n        case GenTemplateRange.Year:\n          subtractDate = dayjs().subtract(1, 'year')\n          break\n        default:\n          subtractDate = dayjs().subtract(99, 'year')\n          break\n      }\n      const marksByRange = latestMarks.filter(item => dayjs(item.createdAt).isAfter(subtractDate))\n\n      // Calculate categorizedMarks with latest marks\n      const categorizedMarks = {\n        scanMarks: marksByRange.filter(item => item.type === 'scan'),\n        textMarks: marksByRange.filter(item => item.type === 'text'),\n        imageMarks: marksByRange.filter(item => item.type === 'image'),\n        linkMarks: marksByRange.filter(item => item.type === 'link'),\n        fileMarks: marksByRange.filter(item => item.type === 'file')\n      }\n\n      // Process image marks\n      const processedImageMarks = await Promise.all(\n        categorizedMarks.imageMarks.map(async (image) => {\n          if (!image.url.includes('http')) {\n            image.url = await convertImage(`/image/${image.url}`)\n          }\n          return image\n        })\n      )\n\n      const store = await Store.load('store.json')\n      const locale = await store.get<string>('locale') || 'zh'\n\n      const request_content = `\n        Here are text fragments recognized by OCR after screenshots:\n        ${categorizedMarks.scanMarks.map((item, index) => `Record ${index + 1}: ${item.content}. Created at ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\\n\\n')}.\n        Here are text fragments copied and recorded:\n        ${categorizedMarks.textMarks.map((item, index) => `Record ${index + 1}: ${item.content}. Created at ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\\n\\n')}.\n        Here are image record descriptions:\n        ${processedImageMarks.map(item => `\n          Description: ${item.content},\n          Image URL: ${item.url}\n        `).join(';\\n\\n')}.\n        Here are link record contents:\n        ${categorizedMarks.linkMarks.map((item, index) => `Link record ${index + 1}:\n          Title: ${item.desc}\n          URL: ${item.url}\n          Content: ${item.content}\n          Created at: ${dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}`).join(';\\n\\n')}.\n        Here are file record descriptions:\n        ${categorizedMarks.fileMarks.map(item => `\n          Content: ${item.content},\n        `).join(';\\n\\n')}.\n        ---\n        ${inputValue ? 'Requirements: '+inputValue : ''}\n        If the record content is empty, return that there is no record information in this organization.\n        Format requirements:\n        - Use ${locale} language for the output.\n        - Use Markdown syntax.\n        - Ensure there is a level 1 heading (H1).\n        - The note order may be incorrect, arrange them in the correct order.\n        - If there are link records, place them as reference links at the end of the article in the following format:\n          ## References\n          1. [Title1](Link1)\n          2. [Title2](Link2)\n\n        ${\n          processedImageMarks.length > 0 ?\n          '- If there are image records, place the image links in appropriate positions in the note based on the image descriptions. The image URLs contain uuid, please return them completely, and add a brief description for each image.'\n          : ''\n        }\n        ${selectedTemplate?.content}\n      `\n\n      // Emit AI streaming start event with target file path\n      emitter.emit('editor-ai-streaming', {\n        isStreaming: true,\n        targetFilePath: filePath,\n        terminate: () => {\n          terminateGeneration()\n        }\n      })\n\n      // 5. Stream generation to editor\n\n      // Skip sync for AI-generated content\n      setSkipSyncOnSave(true)\n      setAiGeneratingFilePath(filePath)\n      setAiTerminateFn(() => {\n        if (abortControllerRef.current) {\n          abortControllerRef.current.abort()\n          abortControllerRef.current = null\n          setLoading(false)\n        }\n      })\n\n      abortControllerRef.current = new AbortController()\n      const signal = abortControllerRef.current.signal\n      const targetFilePath = filePath // 保存目标文件路径\n\n      let fullContent = ''\n      let streamFinished = false\n      await fetchAiStream(request_content, async (content) => {\n        // Check if user switched to a different file - stop writing if so\n        const currentActivePath = useArticleStore.getState().activeFilePath\n        if (currentActivePath !== targetFilePath) {\n          return\n        }\n\n        fullContent = content\n        // Update editor content in real-time without reloading file\n        setCurrentArticle(content)\n        emitter.emit('external-content-update', content)\n        // Also write to file\n        if (workspace.isCustom) {\n          await writeTextFile(pathOptions.path, content)\n        } else {\n          await writeTextFile(pathOptions.path, content, { baseDir: pathOptions.baseDir })\n        }\n      }, signal)\n      streamFinished = true\n\n      // Re-enable sync after AI generation\n      setSkipSyncOnSave(false)\n      setAiGeneratingFilePath(null)\n      setAiTerminateFn(null)\n\n      // Emit AI streaming end event\n      emitter.emit('editor-ai-streaming', {\n        isStreaming: false,\n        targetFilePath: filePath\n      })\n\n      // 6. Extract title and rename file\n      const cleanedContent = fullContent\n\n      // Try to extract title: H1 -> H2 -> H3\n      let titleMatch = cleanedContent.match(/^#\\s+(.+)$/m)\n      if (!titleMatch) {\n        titleMatch = cleanedContent.match(/^##\\s+(.+)$/m)\n      }\n      if (!titleMatch) {\n        titleMatch = cleanedContent.match(/^###\\s+(.+)$/m)\n      }\n\n      if (titleMatch && titleMatch[1]) {\n        const title = titleMatch[1].trim()\n        const sanitizedTitle = title.replace(/[\\/\\\\:*?\"<>|]/g, '_').substring(0, 50)\n\n        // Check for duplicate filenames and add (1), (2) etc if needed\n        let newFileName = `${sanitizedTitle}.md`\n        let counter = 1\n        let newFilePath = newFileName\n        let newPathOptions = await getFilePathOptions(newFilePath)\n\n        while (await exists(newPathOptions.path, workspace.isCustom ? undefined : { baseDir: newPathOptions.baseDir })) {\n          newFileName = `${sanitizedTitle}(${counter}).md`\n          newFilePath = newFileName\n          newPathOptions = await getFilePathOptions(newFilePath)\n          counter++\n        }\n\n        // Write to new file\n        if (workspace.isCustom) {\n          await writeTextFile(newPathOptions.path, cleanedContent)\n        } else {\n          await writeTextFile(newPathOptions.path, cleanedContent, { baseDir: newPathOptions.baseDir })\n        }\n\n        // Delete old file\n        const { remove } = await import('@tauri-apps/plugin-fs')\n        if (workspace.isCustom) {\n          await remove(pathOptions.path)\n        } else {\n          await remove(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        // Update file tree and active file\n        await loadFileTree()\n        setActiveFilePath(newFilePath)\n        await readArticle(newFilePath, '', true)\n        if (shouldEmitOrganizeOnboardingComplete({ streamFinished, aborted: signal.aborted })) {\n          emitter.emit('onboarding-step-complete', { step: 'organize-note', filePath: newFilePath })\n        }\n\n        toast({\n          description: tMark('toolbar.organizeSuccess', { title: sanitizedTitle }),\n        })\n      } else {\n        // No title found, just save the cleaned content\n        if (workspace.isCustom) {\n          await writeTextFile(pathOptions.path, cleanedContent)\n        } else {\n          await writeTextFile(pathOptions.path, cleanedContent, { baseDir: pathOptions.baseDir })\n        }\n        await readArticle(filePath, '', true)\n        if (shouldEmitOrganizeOnboardingComplete({ streamFinished, aborted: signal.aborted })) {\n          emitter.emit('onboarding-step-complete', { step: 'organize-note', filePath })\n        }\n\n        toast({\n          description: tMark('toolbar.organizeSuccess', { title: fileName }),\n        })\n      }\n\n    } catch (error: any) {\n      if (error.name !== 'AbortError') {\n        console.error('Organize error:', error)\n        toast({\n          description: tMark('toolbar.organizeError'),\n          variant: 'destructive',\n        })\n      }\n    } finally {\n      abortControllerRef.current = null\n      setLoading(false)\n      // Re-enable sync in case of termination\n      setSkipSyncOnSave(false)\n      setAiGeneratingFilePath(null)\n      setAiTerminateFn(null)\n      // Emit AI streaming end event\n      emitter.emit('editor-ai-streaming', {\n        isStreaming: false,\n        targetFilePath: filePath\n      })\n    }\n  }, [primaryModel, categorizedMarks, selectedTemplate, inputValue, fetchMarks, loadFileTree, setActiveFilePath, setLeftSidebarTab, setCurrentArticle, readArticle, tMark, t, open])\n\n  useImperativeHandle(ref, () => ({\n    openOrganize\n  }))\n\n  // Listen for abort event from editor\n  useEffect(() => {\n    const handleAbortAiStreaming = () => {\n      if (loading) {\n        terminateGeneration()\n      }\n    }\n    emitter.on('abort-ai-streaming', handleAbortAiStreaming)\n    return () => {\n      emitter.off('abort-ai-streaming', handleAbortAiStreaming)\n    }\n  }, [loading, terminateGeneration])\n\n  const handleDialogKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (!open || e.nativeEvent.isComposing) return\n\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      handleOrganize()\n      return\n    }\n\n    if (e.key === 'Escape') {\n      e.preventDefault()\n      if (loading) {\n        terminateGeneration()\n      } else {\n        setOpen(false)\n      }\n    }\n  }, [open, loading, handleOrganize, terminateGeneration])\n\n  const handleSetting = useCallback(() => {\n    router.push('/core/setting/template')\n  }, [router])\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent onKeyDown={handleDialogKeyDown}>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{t('organizeAs')}</AlertDialogTitle>\n          <Tabs defaultValue={tab} onValueChange={value => setTab(value)}>\n            <TabsList>\n              {\n                genTemplate.map(item => (\n                  <TabsTrigger value={item.id} key={item.id}>{item.title}</TabsTrigger>\n                ))\n              }\n            </TabsList>\n          </Tabs>\n        </AlertDialogHeader>\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"space-y-1\">\n            <div className=\"flex items-center justify-between mb-2\">\n              <Label htmlFor=\"name\">{t('templateContent')}</Label>\n              <div className=\"flex items-center gap-2\">\n                <Label className=\"text-muted-foreground\">{tMark('toolbar.currentTag')}: {currentTag?.name || '-'}</Label>\n                <Label>{t('recordRange')}: { selectedTemplate?.range }</Label>\n              </div>\n            </div>\n            <ScrollArea className=\"h-32 w-full p-2 rounded-md border\">\n              <p className=\"text-xs text-muted-foreground whitespace-pre-wrap\">\n                { selectedTemplate?.content }\n              </p>\n            </ScrollArea>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Checkbox id=\"remove-thinking\" checked={isRemoveThinking} onCheckedChange={(checked) => setIsRemoveThinking(checked === true)} />\n            <Label htmlFor=\"remove-thinking\">{t('filterThinkingContent')}</Label>\n          </div>\n        </div>\n        <AlertDialogFooter>\n          <Button variant={\"ghost\"} disabled={loading} onClick={handleSetting}>{t('manageTemplate')}</Button>\n          <Button variant={\"outline\"} onClick={() => setOpen(false)}>{t('cancel')}</Button>\n          <Button onClick={handleOrganize} disabled={!marks || marks.length === 0 || loading}>{t('startOrganize')}</Button>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n})\n\nOrganizeNotes.displayName = 'OrganizeNotes';\n"
  },
  {
    "path": "src/app/core/main/mark/organize-onboarding.ts",
    "content": "export function shouldEmitOrganizeOnboardingComplete({\n  streamFinished,\n  aborted,\n}: {\n  streamFinished: boolean\n  aborted: boolean\n}) {\n  return streamFinished && !aborted\n}\n"
  },
  {
    "path": "src/app/core/main/mark/tag-item.tsx",
    "content": "import {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"@/components/ui/enhanced-context-menu\"\nimport { Lock, Pin, TagIcon } from \"lucide-react\"\nimport { delTag, Tag, updateTag } from \"@/db/tags\"\nimport React from \"react\"\nimport { Input } from \"@/components/ui/input\"\nimport { Button } from \"@/components/ui/button\"\nimport useTagStore from \"@/stores/tag\"\nimport { useTranslations } from 'next-intl'\nimport { useTextSize } from \"@/contexts/text-size-context\"\n\nfunction ItemIcon({ isLocked=false, isPin=false }) {\n  if (isLocked) {\n    return <Lock className=\"scale-75 text-gray-500\" />\n  } else {\n    if (isPin) {\n      return <Pin className=\"scale-75 text-gray-500\" />\n    } else {\n      return <TagIcon className=\"scale-75 text-gray-500\" />\n    }\n  }\n}\n\nfunction ItemContent({ value, isEditing, onChange }: { value: string, isEditing: boolean, onChange: (name: string) => void }) {\n  const t = useTranslations();\n  const [name, setName] = React.useState(value)\n  if (isEditing) {\n    return (\n      <div className=\"flex w-full max-w-sm items-center space-x-2\">\n        <Input\n          className=\"w-[320px]\"\n          type=\"text\"\n          value={name}\n          onChange={(e) => { setName(e.target.value) }}\n        />\n        <Button type=\"submit\" onClick={async() => { \n          onChange(name)\n        }}>{t('record.mark.tag.rename')}</Button>\n      </div>\n    )\n  } else {\n    return <span>{value}</span>\n  }\n}\n\n\nexport function TagItem(\n  { tag, onChange, onSelect }:\n  { tag: Tag, onChange: () => void, onSelect: () => void }) \n{\n  const t = useTranslations();\n  const { getContextMenuTextSize } = useTextSize()\n  const [isEditing, setIsEditing] = React.useState(false)\n  const textSize = getContextMenuTextSize('record')\n\n  const { fetchTags, getCurrentTag, currentTagId } = useTagStore()\n\n  async function handleDel() {\n    await delTag(tag.id)\n    onChange()\n  }\n\n  async function togglePin() {\n    await updateTag({ ...tag, isPin: !tag.isPin })\n    onChange()\n  }\n\n  async function updateName(name: string) {\n    setIsEditing(false)\n    await updateTag({ ...tag, name })\n    await fetchTags()\n    getCurrentTag()\n    onChange()\n  }\n\n  function handleSelect() {\n    if (!isEditing) {\n      onSelect()\n    }\n  }\n\n  return (\n    <ContextMenu>\n      <ContextMenuTrigger onClick={handleSelect}>\n        <div className={`\n          ${tag.id === currentTagId ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}\n          flex justify-between items-center w-full cursor-pointer rounded px-2 py-1.5 text-${textSize} transition-colors\n        `}>\n          <div className=\"flex gap-2 items-center min-w-0 flex-1\">\n            <ItemIcon isLocked={tag.isLocked} isPin={tag.isPin} />\n            <ItemContent value={tag.name} isEditing={isEditing} onChange={updateName} />\n          </div>\n          <span className={`text-${textSize} ml-2 flex-shrink-0 ${\n            tag.id === currentTagId ? 'text-primary-foreground/70' : 'text-muted-foreground'\n          }`}>\n            {tag.total && tag.total > 0 ? tag.total : ''}\n          </span>\n        </div>\n      </ContextMenuTrigger>\n      <ContextMenuContent>\n        <ContextMenuItem inset disabled={tag.isLocked} onClick={togglePin} menuType=\"record\">\n          { tag.isPin ? t('record.mark.tag.unpin') : t('record.mark.tag.pin') }\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={isEditing} onClick={setIsEditing.bind(null, true)} menuType=\"record\">\n          {t('record.mark.tag.rename')}\n        </ContextMenuItem>\n        <ContextMenuItem inset disabled={tag.isLocked} onClick={handleDel} menuType=\"record\">\n          {t('record.mark.tag.delete')}\n        </ContextMenuItem>\n      </ContextMenuContent>\n    </ContextMenu>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/tag-manage.tsx",
    "content": "\"use client\"\nimport * as React from \"react\"\nimport { useTranslations } from 'next-intl'\nimport { Plus, TagIcon, Inbox, SquareCheck } from \"lucide-react\"\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Empty,\n  EmptyHeader,\n  EmptyMedia,\n  EmptyTitle,\n  EmptyDescription,\n} from \"@/components/ui/empty\"\nimport { initTagsDb, insertTag, Tag, delTag, updateTag, updateTagsOrder } from \"@/db/tags\"\nimport type { Mark } from \"@/db/marks\"\nimport useTagStore from \"@/stores/tag\"\nimport useMarkStore from \"@/stores/mark\"\nimport useChatStore from \"@/stores/chat\"\nimport { MarkLoading } from './mark-loading'\nimport { ImageGallery } from './image-gallery'\nimport { filterMarks } from './mark-filters.mjs'\nimport { MarkListDefaultView } from './mark-list-default-view'\nimport { MarkListCompactView } from './mark-list-compact-view'\nimport { MarkListCardView } from './mark-list-card-view'\nimport emitter from '@/lib/emitter'\nimport { EmitterRecordEvents } from '@/config/emitters'\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuTrigger,\n} from \"@/components/ui/enhanced-context-menu\"\nimport { TagMobileActions } from './tag-mobile-actions'\nimport { useTextSize } from \"@/contexts/text-size-context\"\nimport {\n  DndContext,\n  closestCenter,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\n// Wrapper for AccordionItem that accepts sortable props\nfunction AccordionItemWrapper({ \n  value, \n  children,\n  sortableAttributes,\n  sortableListeners,\n  sortableActivatorRef,\n  ...props \n}: any) {\n  return (\n    <AccordionItem value={value} {...props}>\n      {React.Children.map(children, (child) => {\n        if (React.isValidElement(child) && child.type === ContextMenu) {\n          return React.cloneElement(child as React.ReactElement, {\n            children: React.Children.map((child as React.ReactElement).props.children, (contextChild: any) => {\n              if (React.isValidElement(contextChild) && contextChild.type === ContextMenuTrigger) {\n                return React.cloneElement(contextChild as React.ReactElement, {\n                  children: React.Children.map((contextChild as React.ReactElement).props.children, (triggerChild: any) => {\n                    // 将 sortable 属性应用到 AccordionTrigger\n                    if (React.isValidElement(triggerChild) && triggerChild.type === AccordionTrigger) {\n                      return (\n                        <div ref={sortableActivatorRef} {...sortableAttributes} {...sortableListeners}>\n                          {triggerChild}\n                        </div>\n                      )\n                    }\n                    return triggerChild\n                  })\n                })\n              }\n              return contextChild\n            })\n          })\n        }\n        return child\n      })}\n    </AccordionItem>\n  )\n}\n\n// Sortable Tag Item Component\nfunction SortableTagItem({ tag, children }: { tag: Tag; children: React.ReactNode }) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n    setActivatorNodeRef,\n  } = useSortable({ id: tag.id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  // 将拖拽激活器引用传递给子组件\n  return (\n    <div ref={setNodeRef} style={style}>\n      {React.cloneElement(children as React.ReactElement, { \n        sortableAttributes: attributes,\n        sortableListeners: listeners,\n        sortableActivatorRef: setActivatorNodeRef\n      })}\n    </div>\n  )\n}\n\nexport function TagManage() {\n  const t = useTranslations();\n  const { getContextMenuTextSize } = useTextSize()\n  const [newTagName, setNewTagName] = React.useState<string>(\"\")\n  const [isAdding, setIsAdding] = React.useState(false)\n  const [editingTagId, setEditingTagId] = React.useState<number | null>(null)\n  const [editingName, setEditingName] = React.useState<string>(\"\")\n  const [expandedTagId, setExpandedTagId] = React.useState<string | undefined>(undefined)\n  const [hasInitialized, setHasInitialized] = React.useState(false)\n  const { init } = useChatStore()\n  const textSize = getContextMenuTextSize('record')\n\n  // 自定义传感器，忽略记录项的拖拽\n  const customPointerSensor = useSensor(PointerSensor, {\n    activationConstraint: {\n      delay: 250,\n      tolerance: 5,\n    },\n  })\n\n  const sensors = useSensors(\n    customPointerSensor,\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    })\n  )\n\n  // 处理拖拽开始，检查是否是记录项\n  const handleDragStart = (event: any) => {\n    const target = event.active?.node?.current as HTMLElement\n    \n    // 如果拖拽的是记录项，取消 dnd-kit 拖拽\n    if (target?.querySelector('[data-mark-item]') || target?.closest('[data-mark-item]')) {\n      event.cancel()\n    }\n  }\n\n  const {\n    currentTag,\n    currentTagId,\n    tags,\n    fetchTags,\n    initTags,\n    setCurrentTagId,\n    getCurrentTag\n  } = useTagStore()\n\n  const {\n    marks,\n    queues,\n    fetchMarks,\n    recordFilters,\n    recordViewMode,\n    hasActiveRecordFilters,\n    setVisibleMarkIds,\n    pendingScrollMarkId,\n    setPendingScrollMarkId,\n    highlightedMarkId,\n    setHighlightedMarkId,\n  } = useMarkStore()\n\n  async function handleAddTag() {\n    if (!newTagName.trim()) return\n    const res = await insertTag({ name: newTagName.trim() })\n    const newTagId = res.lastInsertId as number\n    await setCurrentTagId(newTagId)\n    await fetchTags()\n    getCurrentTag()\n    await fetchMarks()\n    await init(newTagId)\n    setNewTagName(\"\")\n    setIsAdding(false)\n    // 添加新标签后自动展开\n    setExpandedTagId(newTagId.toString())\n  }\n\n  async function handleSelectTag(tag: Tag) {\n    await setCurrentTagId(tag.id)\n    getCurrentTag()\n    await fetchMarks()\n    await init(tag.id)\n  }\n\n  async function handleDeleteTag(tagId: number) {\n    await delTag(tagId)\n    await fetchTags()\n    getCurrentTag()\n  }\n\n  async function handleRename(tag: Tag) {\n    if (!editingName.trim()) return\n    await updateTag({ ...tag, name: editingName.trim() })\n    await fetchTags()\n    getCurrentTag()\n    setEditingTagId(null)\n    setEditingName(\"\")\n  }\n\n  function startEditing(tag: Tag) {\n    setEditingTagId(tag.id)\n    setEditingName(tag.name)\n  }\n\n  // 获取当前标签下的记录\n  const getTagMarks = (tagId: number) => {\n    return marks.filter(mark => mark.tagId === tagId)\n  }\n\n  const filtersActive = hasActiveRecordFilters()\n\n  const getFilteredTagMarks = React.useCallback((tagId: number) => {\n    return filterMarks(getTagMarks(tagId), {\n      ...recordFilters,\n      tagId: 'all',\n    })\n  }, [marks, recordFilters])\n\n  const visibleTags = React.useMemo(() => {\n    return tags.filter((tag) => {\n      if (recordFilters.tagId !== 'all' && tag.id !== recordFilters.tagId) {\n        return false\n      }\n\n      if (!filtersActive) {\n        return true\n      }\n\n      const hasQueue = queues.some((queue) => queue.tagId === tag.id)\n      return getFilteredTagMarks(tag.id).length > 0 || hasQueue\n    })\n  }, [filtersActive, getFilteredTagMarks, queues, recordFilters.tagId, tags])\n\n  const visibleMarkIds = React.useMemo(() => {\n    return visibleTags.flatMap((tag) => getFilteredTagMarks(tag.id).map((mark: Mark) => mark.id))\n  }, [getFilteredTagMarks, visibleTags])\n\n  // 处理拖拽结束\n  async function handleDragEnd(event: DragEndEvent) {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const oldIndex = tags.findIndex((tag) => tag.id === active.id)\n      const newIndex = tags.findIndex((tag) => tag.id === over.id)\n\n      const newTags = arrayMove(tags, oldIndex, newIndex)\n      \n      // 更新本地状态\n      const updatedTags = newTags.map((tag, index) => ({\n        ...tag,\n        sortOrder: index\n      }))\n      \n      // 批量更新数据库\n      await updateTagsOrder(updatedTags.map(tag => ({ id: tag.id, sortOrder: tag.sortOrder || 0 })))\n      await fetchTags()\n    }\n  }\n\n  React.useEffect(() => {\n    const fetchData = async() => {\n      await initTagsDb()\n      await fetchTags()\n      await initTags()\n      await fetchMarks()\n    }\n    fetchData()\n  }, [initTags, fetchTags, fetchMarks])\n\n  // 初始化时展开当前标签（只执行一次）\n  React.useEffect(() => {\n    if (currentTag && !hasInitialized) {\n      setExpandedTagId(currentTag.id.toString())\n      setHasInitialized(true)\n    }\n  }, [currentTag, hasInitialized])\n\n  // 监听刷新事件，展开当前标签\n  React.useEffect(() => {\n    const handleRefresh = () => {\n      if (currentTagId) {\n        setExpandedTagId(currentTagId.toString())\n        fetchMarks()\n      }\n    }\n    \n    emitter.on(EmitterRecordEvents.refreshMarks, handleRefresh)\n    \n    return () => {\n      emitter.off(EmitterRecordEvents.refreshMarks, handleRefresh)\n    }\n  }, [currentTagId, fetchMarks])\n\n  React.useEffect(() => {\n    if (!pendingScrollMarkId || expandedTagId !== currentTagId.toString()) {\n      return\n    }\n\n    if (!marks.some((mark) => mark.id === pendingScrollMarkId && mark.tagId === currentTagId)) {\n      return\n    }\n\n    let cancelled = false\n    let attempts = 0\n    const maxAttempts = 20\n\n    const scrollToTarget = () => {\n      if (cancelled) return\n\n      const target = document.querySelector<HTMLElement>(`[data-mark-id=\"${pendingScrollMarkId}\"]`)\n      if (target) {\n        target.scrollIntoView({ behavior: 'smooth', block: 'center' })\n        setHighlightedMarkId(pendingScrollMarkId)\n        setPendingScrollMarkId(null)\n        return\n      }\n\n      if (attempts >= maxAttempts) {\n        setPendingScrollMarkId(null)\n        return\n      }\n\n      attempts += 1\n      window.setTimeout(scrollToTarget, 50)\n    }\n\n    scrollToTarget()\n\n    return () => {\n      cancelled = true\n    }\n  }, [currentTagId, expandedTagId, marks, pendingScrollMarkId, setHighlightedMarkId, setPendingScrollMarkId])\n\n  React.useEffect(() => {\n    if (!highlightedMarkId) {\n      return\n    }\n\n    const clearHighlightTimer = window.setTimeout(() => {\n      setHighlightedMarkId(null)\n    }, 3000)\n\n    return () => {\n      clearTimeout(clearHighlightTimer)\n    }\n  }, [highlightedMarkId, setHighlightedMarkId])\n\n  React.useEffect(() => {\n    setVisibleMarkIds(visibleMarkIds)\n    return () => setVisibleMarkIds([])\n  }, [setVisibleMarkIds, visibleMarkIds])\n\n  const renderTagRecords = React.useCallback((tagId: number) => {\n    const filteredMarks = getFilteredTagMarks(tagId).filter((mark: Mark) => {\n      if (mark.type === 'image' || mark.type === 'scan') {\n        return mark.content && mark.content.trim() !== ''\n      }\n      return true\n    })\n\n    if (filteredMarks.length === 0 && queues.filter(queue => queue.tagId === tagId).length === 0) {\n      return (\n        <Empty className=\"border-0 py-8\">\n          <EmptyHeader>\n            <EmptyMedia variant=\"icon\">\n              <Inbox />\n            </EmptyMedia>\n            <EmptyTitle className=\"text-sm\">{t('record.mark.empty')}</EmptyTitle>\n            <EmptyDescription className=\"text-xs\">\n              {t('record.mark.mark.emptyHint')}\n            </EmptyDescription>\n          </EmptyHeader>\n        </Empty>\n      )\n    }\n\n    switch (recordViewMode) {\n    case 'compact':\n      return <MarkListCompactView marks={filteredMarks} />\n    case 'cards':\n      return <MarkListCardView marks={filteredMarks} />\n    case 'list':\n    default:\n      return <MarkListDefaultView marks={filteredMarks} />\n    }\n  }, [getFilteredTagMarks, queues, recordViewMode, t])\n\n  return (\n    <div className=\"w-full\">\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCenter}\n        onDragStart={handleDragStart}\n        onDragEnd={handleDragEnd}\n      >\n        <SortableContext\n          items={visibleTags.map(tag => tag.id)}\n          strategy={verticalListSortingStrategy}\n        >\n          {/* 标签列表 */}\n          <Accordion \n            type=\"single\" \n            collapsible \n            value={expandedTagId} \n            onValueChange={(value) => {\n              // 直接设置展开状态，允许折叠（value 为 undefined）\n              setExpandedTagId(value)\n            }}\n            className=\"w-full\"\n          >\n            {visibleTags.length === 0 ? (\n              <Empty className=\"border-0 py-10\">\n                <EmptyHeader>\n                  <EmptyMedia variant=\"icon\">\n                    <Inbox />\n                  </EmptyMedia>\n                  <EmptyTitle className=\"text-sm\">{t('record.mark.list.emptyFiltered')}</EmptyTitle>\n                  <EmptyDescription className=\"text-xs\">\n                    {t('record.mark.list.emptyFilteredHint')}\n                  </EmptyDescription>\n                </EmptyHeader>\n              </Empty>\n            ) : visibleTags.map((tag) => (\n              <SortableTagItem key={tag.id} tag={tag}>\n                <AccordionItemWrapper value={tag.id.toString()}>\n                  <ContextMenu>\n                    <ContextMenuTrigger>\n                      <AccordionTrigger \n                        className={`px-3 py-2 hover:no-underline opacity-50 ${currentTagId === tag.id && 'bg-accent opacity-100'}`}\n                        onClick={() => {\n                          if (tag.id !== currentTagId) {\n                            handleSelectTag(tag)\n                          }\n                        }}\n                      >\n                        <div className=\"flex items-center gap-2 flex-1\">\n                          {\n                            currentTagId === tag.id ? \n                            <SquareCheck className=\"size-3\" />:\n                            <TagIcon className=\"size-3\" />\n                          }\n                          {editingTagId === tag.id ? (\n                            <Input\n                              value={editingName}\n                              onChange={(e) => setEditingName(e.target.value)}\n                              onKeyDown={(e) => {\n                                if (e.key === 'Enter') handleRename(tag)\n                                if (e.key === 'Escape') setEditingTagId(null)\n                                e.stopPropagation()\n                              }}\n                              onClick={(e) => e.stopPropagation()}\n                              className=\"h-6 text-sm\"\n                              autoFocus\n                            />\n                          ) : (\n                            <div className=\"text-xs w-full flex items-center justify-between gap-2\">\n                              <span className={`flex-1 ${currentTagId === tag.id && 'font-bold'}`}>{tag.name}</span>\n                              <span className=\"text-muted-foreground\">{tag.total && tag.total > 0 ? tag.total : ''}</span>\n                              <TagMobileActions \n                                tag={tag}\n                                onRename={startEditing}\n                                onDelete={handleDeleteTag}\n                                isEditing={editingTagId === tag.id}\n                              />\n                            </div>\n                          )}\n                        </div>\n                      </AccordionTrigger>\n                    </ContextMenuTrigger>\n                    <ContextMenuContent>\n                      <ContextMenuItem disabled={editingTagId === tag.id} onClick={() => startEditing(tag)}>\n                        {t('record.mark.tag.rename')}\n                      </ContextMenuItem>\n                      <ContextMenuItem disabled={tag.isLocked} onClick={() => handleDeleteTag(tag.id)}>\n                        <span className=\"text-red-600\">{t('record.mark.tag.delete')}</span>\n                      </ContextMenuItem>\n                    </ContextMenuContent>\n                  </ContextMenu>\n                  <AccordionContent className=\"px-0 pb-0\">\n\n                    {/* 显示当前标签的队列（正在处理中的记录） */}\n                    {queues.filter(queue => queue.tagId === tag.id).map((queue) => (\n                      <MarkLoading key={queue.queueId} mark={queue} />\n                    ))}\n\n                    {/* 图片画廊 - 显示当前标签下所有无内容的图片 */}\n                    <ImageGallery marks={getFilteredTagMarks(tag.id)} />\n                    \n                    {/* 显示已完成的记录 - 过滤掉没有内容的图片记录 */}\n                    {renderTagRecords(tag.id)}\n                  </AccordionContent>\n                </AccordionItemWrapper>\n              </SortableTagItem>\n            ))}\n          </Accordion>\n        </SortableContext>\n      </DndContext>\n\n      {/* 添加标签 */}\n      <div className=\"p-2\">\n        {isAdding ? (\n          <div className=\"flex gap-2\">\n            <Input\n              placeholder={t('record.mark.tag.newTagPlaceholder')}\n              value={newTagName}\n              onChange={(e) => setNewTagName(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === 'Enter') handleAddTag()\n                if (e.key === 'Escape') {\n                  setIsAdding(false)\n                  setNewTagName(\"\")\n                }\n              }}\n              className={`h-8 text-${textSize}`}\n              autoFocus\n            />\n            <Button size=\"sm\" onClick={handleAddTag} className={`h-8 text-${textSize}`}>\n              {t('record.mark.tag.add')}\n            </Button>\n          </div>\n        ) : (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => setIsAdding(true)}\n            className={`w-full h-8 text-${textSize}`}\n          >\n            <Plus className=\"size-3 mr-1\" />\n            {t('record.mark.tag.newTag')}\n          </Button>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/tag-mobile-actions.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { MoreVertical, Edit2, Trash2 } from 'lucide-react'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { Tag } from '@/db/tags'\nimport { useIsMobile } from '@/hooks/use-mobile'\n\ninterface TagMobileActionsProps {\n  tag: Tag\n  onRename: (tag: Tag) => void\n  onDelete: (tagId: number) => void\n  isEditing: boolean\n}\n\nexport function TagMobileActions({ tag, onRename, onDelete, isEditing }: TagMobileActionsProps) {\n  const t = useTranslations()\n  const isMobile = useIsMobile()\n\n  // 只在移动端显示\n  if (!isMobile) return null\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>\n        <div \n          role=\"button\"\n          tabIndex={0}\n          className=\"inline-flex items-center justify-center h-10 w-10 shrink-0 rounded-md hover:bg-accent hover:text-accent-foreground cursor-pointer\"\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault()\n              e.stopPropagation()\n            }\n          }}\n        >\n          <MoreVertical className=\"h-4 w-4\" />\n        </div>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem \n          disabled={isEditing}\n          onClick={(e) => {\n            e.stopPropagation()\n            onRename(tag)\n          }}\n        >\n          <Edit2 className=\"mr-2 h-4 w-4\" />\n          {t('record.mark.tag.rename')}\n        </DropdownMenuItem>\n        <DropdownMenuItem \n          disabled={tag.isLocked}\n          onClick={(e) => {\n            e.stopPropagation()\n            onDelete(tag.id)\n          }}\n        >\n          <Trash2 className=\"mr-2 h-4 w-4 text-red-600\" />\n          <span className=\"text-red-600\">{t('record.mark.tag.delete')}</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/todo-edit-button.tsx",
    "content": "'use client'\n\nimport { useState } from \"react\"\nimport type { Mark } from \"@/db/marks\"\nimport { cn } from \"@/lib/utils\"\nimport { TodoEditDialog } from \"./todo-edit-dialog\"\n\ntype TodoEditTriggerProps = {\n  mark: Mark\n  className?: string\n  children: React.ReactNode\n}\n\nexport function TodoEditTrigger({ mark, className, children }: TodoEditTriggerProps) {\n  const [open, setOpen] = useState(false)\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        className={cn(\"min-w-0 text-left\", className)}\n      >\n        {children}\n      </button>\n      <TodoEditDialog mark={mark} open={open} onOpenChange={setOpen} />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/todo-edit-dialog.tsx",
    "content": "import { Mark } from \"@/db/marks\"\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Label } from \"@/components/ui/label\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { Button } from \"@/components/ui/button\"\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\nimport { useState, useEffect } from \"react\"\nimport { updateMark } from \"@/db/marks\"\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport { CheckSquare } from \"lucide-react\"\n\ntype Priority = 'low' | 'medium' | 'high'\n\ninterface TodoData {\n  title: string\n  description: string\n  completed: boolean\n  priority: Priority\n}\n\ninterface TodoEditDialogProps {\n  mark: Mark\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function TodoEditDialog({ mark, open, onOpenChange }: TodoEditDialogProps) {\n  const t = useTranslations()\n  const { fetchMarks } = useMarkStore()\n  const { fetchTags, getCurrentTag } = useTagStore()\n\n  const [title, setTitle] = useState('')\n  const [description, setDescription] = useState('')\n  const [priority, setPriority] = useState<Priority>('medium')\n\n  useEffect(() => {\n    if (open && mark) {\n      try {\n        const todoData: TodoData = JSON.parse(mark.content || '{}')\n        setTitle(todoData.title || '')\n        setDescription(todoData.description || '')\n        setPriority(todoData.priority || 'medium')\n      } catch {\n        setTitle(mark.desc || '')\n        setDescription('')\n        setPriority('medium')\n      }\n    }\n  }, [open, mark])\n\n  async function handleSave() {\n    if (!title.trim()) {\n      return\n    }\n\n    const todoData: TodoData = {\n      title: title.trim(),\n      description: description.trim(),\n      priority,\n      completed: false // 编辑后重置完成状态\n    }\n\n    await updateMark({\n      ...mark,\n      desc: title.trim(),\n      content: JSON.stringify(todoData)\n    })\n\n    await fetchMarks()\n    await fetchTags()\n    getCurrentTag()\n\n    onOpenChange(false)\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"min-w-full md:min-w-[550px]\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <CheckSquare className=\"w-5 h-5\" />\n            {t('record.mark.type.todo')}\n          </DialogTitle>\n          <DialogDescription>\n            {t('record.mark.todo.description')}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4\">\n          <div>\n            <Label htmlFor=\"edit-todo-title\">{t('record.mark.todo.title')} *</Label>\n            <Input\n              id=\"edit-todo-title\"\n              value={title}\n              onChange={(e) => setTitle(e.target.value)}\n              placeholder={t('record.mark.todo.titlePlaceholder')}\n              className=\"mt-1.5\"\n            />\n          </div>\n\n          <div>\n            <Label htmlFor=\"edit-todo-description\">{t('record.mark.todo.description')}</Label>\n            <Textarea\n              id=\"edit-todo-description\"\n              rows={3}\n              value={description}\n              onChange={(e) => setDescription(e.target.value)}\n              placeholder={t('record.mark.todo.descriptionPlaceholder')}\n              className=\"mt-1.5\"\n            />\n          </div>\n\n          <div>\n            <Label htmlFor=\"edit-todo-priority\">{t('record.mark.todo.priority')}</Label>\n            <Tabs value={priority} onValueChange={(value) => setPriority(value as Priority)} className=\"mt-1.5\">\n              <TabsList className=\"grid w-full grid-cols-3\">\n                <TabsTrigger value=\"low\" className=\"data-[state=active]:bg-green-800 data-[state=active]:text-white\">\n                  {t('record.mark.todo.priorityLow')}\n                </TabsTrigger>\n                <TabsTrigger value=\"medium\" className=\"data-[state=active]:bg-orange-700 data-[state=active]:text-white\">\n                  {t('record.mark.todo.priorityMedium')}\n                </TabsTrigger>\n                <TabsTrigger value=\"high\" className=\"data-[state=active]:bg-red-900 data-[state=active]:text-white\">\n                  {t('record.mark.todo.priorityHigh')}\n                </TabsTrigger>\n              </TabsList>\n            </Tabs>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t('common.cancel')}\n          </Button>\n          <Button onClick={handleSave} disabled={!title.trim()}>\n            {t('common.save')}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/todo-form.tsx",
    "content": "import { Label } from \"@/components/ui/label\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { Input } from \"@/components/ui/input\"\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\"\nimport { useTranslations } from \"next-intl\"\n\nexport type Priority = 'low' | 'medium' | 'high'\n\nexport interface TodoFormData {\n  title: string\n  description: string\n  priority: Priority\n}\n\ninterface TodoFormProps {\n  mode: 'create' | 'edit'\n  data: TodoFormData\n  onChange: (data: TodoFormData) => void\n  selectedTagId?: number\n  onTagChange?: (tagId: number) => void\n  tags?: Array<{ id: number; name: string }>\n  showTagSelector?: boolean\n}\n\nexport function TodoForm({\n  mode,\n  data,\n  onChange,\n  selectedTagId,\n  onTagChange,\n  tags = [],\n  showTagSelector = false,\n}: TodoFormProps) {\n  const t = useTranslations()\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && !e.shiftKey) {\n      e.preventDefault()\n      // 父组件处理保存\n    } else if (e.key === 'Escape') {\n      // 父组件处理关闭\n    }\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {showTagSelector && onTagChange && (\n        <div>\n          <Label htmlFor=\"todo-tag\">{t('record.mark.todo.selectTag')}</Label>\n          <Select value={String(selectedTagId)} onValueChange={(value) => onTagChange(Number(value))}>\n            <SelectTrigger className=\"mt-1.5\">\n              <SelectValue placeholder={t('record.mark.todo.selectTag')} />\n            </SelectTrigger>\n            <SelectContent>\n              {tags.map((tag) => (\n                <SelectItem key={tag.id} value={String(tag.id)}>\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"truncate\">{tag.name}</span>\n                  </div>\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n      )}\n\n      <div>\n        <Label htmlFor={`todo-title-${mode}`}>{t('record.mark.todo.title')} *</Label>\n        <Input\n          id={`todo-title-${mode}`}\n          value={data.title}\n          onChange={(e) => onChange({ ...data, title: e.target.value })}\n          placeholder={t('record.mark.todo.titlePlaceholder')}\n          onKeyDown={handleKeyDown}\n          autoFocus\n          className=\"mt-1.5\"\n        />\n      </div>\n\n      <div>\n        <Label htmlFor={`todo-description-${mode}`}>{t('record.mark.todo.description')}</Label>\n        <Textarea\n          id={`todo-description-${mode}`}\n          rows={3}\n          value={data.description}\n          onChange={(e) => onChange({ ...data, description: e.target.value })}\n          placeholder={t('record.mark.todo.descriptionPlaceholder')}\n          className=\"mt-1.5\"\n        />\n      </div>\n\n      <div>\n        <Label htmlFor={`todo-priority-${mode}`}>{t('record.mark.todo.priority')}</Label>\n        <Tabs value={data.priority} onValueChange={(value) => onChange({ ...data, priority: value as Priority })} className=\"mt-1.5\">\n          <TabsList className=\"grid w-full grid-cols-3\">\n            <TabsTrigger value=\"low\" className=\"gap-2 data-[state=active]:bg-accent\">\n              <span className=\"w-2 h-2 rounded-full bg-green-500\" />\n              {t('record.mark.todo.priorityLow')}\n            </TabsTrigger>\n            <TabsTrigger value=\"medium\" className=\"gap-2 data-[state=active]:bg-accent\">\n              <span className=\"w-2 h-2 rounded-full bg-orange-500\" />\n              {t('record.mark.todo.priorityMedium')}\n            </TabsTrigger>\n            <TabsTrigger value=\"high\" className=\"gap-2 data-[state=active]:bg-accent\">\n              <span className=\"w-2 h-2 rounded-full bg-red-500\" />\n              {t('record.mark.todo.priorityHigh')}\n            </TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/mark/todo-item-content.tsx",
    "content": "import { Mark } from \"@/db/marks\"\nimport { useTranslations } from 'next-intl'\nimport dayjs from \"dayjs\"\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport { updateMark } from \"@/db/marks\"\nimport { useState } from \"react\"\nimport { CheckSquare, Square } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport useMarkStore from \"@/stores/mark\"\nimport useSettingStore from \"@/stores/setting\"\nimport { getMarkTypeListBadgeClasses } from \"./mark-type-meta\"\nimport { parseTodoMarkContent } from \"./mark-list-item-content\"\nimport { TodoEditTrigger } from \"./todo-edit-button\"\nimport { Priority } from \"./todo-form\"\n\ndayjs.extend(relativeTime)\n\ninterface TodoData {\n  title: string\n  description: string\n  completed: boolean\n  priority: Priority\n}\n\nexport function TodoItemContent({ mark }: { mark: Mark }) {\n  const t = useTranslations()\n  const { fetchMarks } = useMarkStore()\n  const { recordTextSize } = useSettingStore()\n\n  const [todoData, setTodoData] = useState<TodoData>(() => {\n    return parseTodoMarkContent(mark)\n  })\n\n  // 根据文字大小映射行高\n  const getLineHeight = (textSize: string) => {\n    const heightMap = {\n      'xs': 'leading-3',\n      'sm': 'leading-4',\n      'md': 'leading-5',\n      'lg': 'leading-6',\n      'xl': 'leading-7'\n    }\n    return heightMap[textSize as keyof typeof heightMap] || 'leading-4'\n  }\n\n  const lineHeight = getLineHeight(recordTextSize)\n\n  // 获取优先级颜色（用于圆点）\n  const getPriorityColor = (priority: Priority) => {\n    const colors = {\n      low: 'bg-green-500',\n      medium: 'bg-orange-500',\n      high: 'bg-red-500'\n    }\n    return colors[priority]\n  }\n\n  // 切换完成状态\n  const handleToggleComplete = async () => {\n    const newData = { ...todoData, completed: !todoData.completed }\n    setTodoData(newData)\n\n    await updateMark({\n      ...mark,\n      content: JSON.stringify(newData)\n    })\n\n    await fetchMarks()\n  }\n\n  const priorityDotColor = getPriorityColor(todoData.priority)\n\n  return (\n    <>\n      <div className=\"flex-1 pr-10 md:pr-0 group\">\n        <div className={`flex w-full items-center gap-2 text-zinc-500 text-${recordTextSize} ${lineHeight}`}>\n          <span className={getMarkTypeListBadgeClasses(mark.type, 'xs')}>\n            {t('record.mark.type.todo')}\n          </span>\n\n          {/* 优先级圆点 */}\n          <span className={cn(\"w-2 h-2 rounded-full\", priorityDotColor)} />\n          {/* 创建时间 */}\n          <span className=\"ml-auto\">{dayjs(mark.createdAt).fromNow()}</span>\n        </div>\n\n        {/* 待办内容 */}\n        <div className=\"mt-2\">\n          <div className=\"flex items-center gap-3\">\n            {/* 完成状态复选框 */}\n            <button\n              onClick={handleToggleComplete}\n              className=\"flex-shrink-0 hover:scale-110 transition-transform\"\n            >\n              {todoData.completed ? (\n                <CheckSquare className=\"w-5 h-5 text-green-600\" />\n              ) : (\n                <Square className=\"w-5 h-5 text-zinc-400\" />\n              )}\n            </button>\n\n            <TodoEditTrigger mark={mark} className=\"min-w-0 flex-1\">\n              <p className={cn(\n                `font-medium text-${recordTextSize}`,\n                todoData.completed && \"line-through text-zinc-500\"\n              )}>\n                {todoData.title}\n              </p>\n              {todoData.description && (\n                <div className={cn(\n                  \"mt-1\",\n                  todoData.completed && \"opacity-50\"\n                )}>\n                  <p className={cn(\n                    `text-${recordTextSize} text-muted-foreground line-clamp-2 ${lineHeight}`,\n                    todoData.completed && \"line-through\"\n                  )}>\n                    {todoData.description}\n                  </p>\n                </div>\n              )}\n            </TodoEditTrigger>\n          </div>\n        </div>\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/main/page.tsx",
    "content": "'use client'\n\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from \"@/components/ui/resizable\"\nimport { LeftSidebar } from \"./left-sidebar\"\nimport { EditorLayout } from './editor/editor-layout'\nimport Chat from './chat'\nimport dynamic from 'next/dynamic'\nimport { useSidebarStore } from \"@/stores/sidebar\"\nimport { useEffect, useState, useRef } from 'react'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { ImperativePanelHandle } from 'react-resizable-panels'\nimport { invoke } from \"@tauri-apps/api/core\"\nimport { getCurrentWindow } from \"@tauri-apps/api/window\"\nimport emitter from '@/lib/emitter'\nimport { useRouter } from 'next/navigation'\n\nfunction getDefaultLayout(layoutKey: string) {\n  const storageKey = `react-resizable-panels:main-layout:${layoutKey}`\n  const layout = localStorage.getItem(storageKey);\n  \n  if (layout) {\n    try {\n      const parsed = JSON.parse(layout);\n      // 验证总和是否为 100\n      const sum = parsed.reduce((a: number, b: number) => a + b, 0);\n      if (Math.abs(sum - 100) < 0.1) {\n        return parsed;\n      }\n      // 如果总和不是 100，清除这个无效的值\n      console.warn(`Invalid layout sum ${sum} for ${layoutKey}, using defaults`);\n      localStorage.removeItem(storageKey);\n    } catch (e) {\n      console.error('Failed to parse layout:', e);\n    }\n  }\n  \n  // 根据布局组合返回默认值，但始终返回3个面板的尺寸\n  switch (layoutKey) {\n    case 'left-center-right':\n      return [20, 50, 30]\n    case 'left-center':\n      return [30, 70, 0] // 右侧折叠\n    case 'center-right':\n      return [0, 60, 40] // 左侧折叠\n    case 'left-right':\n      return [50, 0, 50] // 中间折叠\n    case 'left':\n      return [100, 0, 0] // 只有左侧\n    case 'center':\n      return [0, 100, 0] // 只有中间\n    case 'right':\n      return [0, 0, 100] // 只有右侧\n    default:\n      return [30, 40, 30] // 默认三等分\n  }\n}\n\nfunction ResizableWrapper() {\n  const { \n    leftSidebarVisible, \n    centerPanelVisible, \n    rightSidebarVisible, \n    initSidebarState\n  } = useSidebarStore()\n  \n  const leftPanelRef = useRef<ImperativePanelHandle>(null)\n  const centerPanelRef = useRef<ImperativePanelHandle>(null)\n  const rightPanelRef = useRef<ImperativePanelHandle>(null)\n  \n  const MIN_SIDEBAR_WIDTH_PX = 280\n  const MIN_EDITOR_WIDTH_PX = 400\n  const [minSidebarSize, setMinSidebarSize] = useState(20)\n  const [minEditorSize, setMinEditorSize] = useState(30)\n  \n  // 使用稳定的 layoutKey 用于存储，但不作为 React key\n  const visiblePanels = [\n    leftSidebarVisible && 'left',\n    centerPanelVisible && 'center',\n    rightSidebarVisible && 'right'\n  ].filter(Boolean)\n  const layoutKey = visiblePanels.join('-')\n  \n  const calculateMinSizes = () => {\n    const windowWidth = window.innerWidth\n    const minSidebarPercent = Math.max(15, (MIN_SIDEBAR_WIDTH_PX / windowWidth) * 100)\n    const minEditorPercent = Math.max(25, (MIN_EDITOR_WIDTH_PX / windowWidth) * 100)\n    setMinSidebarSize(Math.min(minSidebarPercent, 40))\n    setMinEditorSize(Math.min(minEditorPercent, 50))\n  }\n\n  // 初始化侧边栏状态\n  useEffect(() => {\n    initSidebarState()\n    calculateMinSizes()\n    \n    window.addEventListener('resize', calculateMinSizes)\n    return () => window.removeEventListener('resize', calculateMinSizes)\n  }, [])\n\n  // 当面板可见性变化时，控制面板的折叠和展开\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      // 左侧面板\n      if (leftPanelRef.current) {\n        if (leftSidebarVisible) {\n          leftPanelRef.current.expand()\n        } else {\n          leftPanelRef.current.collapse()\n        }\n      }\n      \n      // 中间面板\n      if (centerPanelRef.current) {\n        if (centerPanelVisible) {\n          centerPanelRef.current.expand()\n        } else {\n          centerPanelRef.current.collapse()\n        }\n      }\n      \n      // 右侧面板\n      if (rightPanelRef.current) {\n        if (rightSidebarVisible) {\n          rightPanelRef.current.expand()\n        } else {\n          rightPanelRef.current.collapse()\n        }\n      }\n    }, 100)\n    return () => clearTimeout(timer)\n  }, [leftSidebarVisible, centerPanelVisible, rightSidebarVisible])\n\n  // 根据面板可见性渲染布局\n  // 注意：左侧面板始终渲染，所以 layoutKey 用于存储，但实际布局计算需要考虑左侧始终存在\n  \n  // 计算实际需要的默认尺寸（所有面板始终存在）\n  const getActualLayout = () => {\n    const savedLayout = getDefaultLayout(layoutKey)\n    \n    // 所有面板都始终渲染，直接返回保存的布局或默认布局\n    if (savedLayout.length === 3) {\n      return savedLayout\n    }\n    \n    // 如果保存的布局不是3个值，使用默认布局\n    return [30, 40, 30] // 左侧30%，中间40%，右侧30%\n  }\n  \n  const actualLayout = getActualLayout()\n  \n  const onLayout = (sizes: number[]) => {\n    // 保存当前面板布局\n    const storageKey = `react-resizable-panels:main-layout:${layoutKey}`\n    localStorage.setItem(storageKey, JSON.stringify(sizes));\n  };\n\n  // 根据可见面板数量动态构建布局\n  const renderLayout = () => {\n    const panels = []\n    let index = 0\n\n    // 左侧面板\n    panels.push(\n      <ResizablePanel\n        key=\"left\"\n        ref={leftPanelRef}\n        defaultSize={actualLayout[index++]}\n        minSize={minSidebarSize}\n        collapsible={true}\n        collapsedSize={0}\n      >\n        <LeftSidebar />\n      </ResizablePanel>\n    )\n\n    // 左侧和中间之间的分隔条\n    // 当中间面板可见时显示；当中间面板不可见但左右都可见时也显示（作为左右分隔条）\n    const shouldShowLeftHandle = leftSidebarVisible && (centerPanelVisible || rightSidebarVisible)\n    panels.push(\n      <ResizableHandle\n        key=\"handle-left-center\"\n        className={`${!shouldShowLeftHandle ? 'hidden' : ''}`}\n      />\n    )\n\n    // 中间面板\n    panels.push(\n      <ResizablePanel\n        key=\"center\"\n        ref={centerPanelRef}\n        defaultSize={actualLayout[index++]}\n        minSize={minEditorSize}\n        collapsible={true}\n        collapsedSize={0}\n      >\n        <EditorLayout />\n      </ResizablePanel>\n    )\n\n    // 中间和右侧之间的分隔条\n    // 只有当中间面板可见时才显示此分隔条\n    panels.push(\n      <ResizableHandle\n        key=\"handle-center-right\"\n        className={`${!centerPanelVisible || !rightSidebarVisible ? 'hidden' : ''}`}\n      />\n    )\n\n    // 右侧面板\n    panels.push(\n      <ResizablePanel\n        key=\"right\"\n        ref={rightPanelRef}\n        defaultSize={actualLayout[index++]}\n        minSize={minSidebarSize}\n        collapsible={true}\n        collapsedSize={0}\n      >\n        <Chat />\n      </ResizablePanel>\n    )\n\n    return panels\n  }\n\n  return (\n    <ResizablePanelGroup \n      direction=\"horizontal\" \n      onLayout={onLayout} \n      className=\"h-full\"\n    >\n      {renderLayout()}\n    </ResizablePanelGroup>\n  )\n}\n\nfunction Page() {\n  const router = useRouter()\n\n  useEffect(() => {\n    // 保存当前页面路径\n    async function saveCurrentPage() {\n      const store = await Store.load('store.json')\n      await store.set('currentPage', '/core/main')\n      await store.save()\n    }\n    saveCurrentPage()\n\n    // 监听托盘事件\n    const window = getCurrentWindow()\n    const unlistenTrayAction = window.listen<string>('tray-action', async (event) => {\n      const action = event.payload\n      switch (action) {\n        case 'screenshot':\n          await invoke('screenshot')\n          emitter.emit('screenshot-shortcut-register', undefined)\n          break\n        case 'text':\n          emitter.emit('text-shortcut-register', undefined)\n          break\n        case 'pin':\n          emitter.emit('window-pin-register', undefined)\n          break\n        case 'link':\n          emitter.emit('link-shortcut-register', undefined)\n          break\n      }\n    })\n\n    // 监听打开设置事件\n    const unlistenOpenSettings = window.listen<void>('open-settings', () => {\n      // 导航到设置页面\n      router.push('/core/setting')\n    })\n\n    return () => {\n      unlistenTrayAction.then(fn => fn())\n      unlistenOpenSettings.then(fn => fn())\n    }\n  }, [router])\n\n  return <ResizableWrapper />\n}\n\nexport default dynamic(() => Promise.resolve(Page), { ssr: false })\n"
  },
  {
    "path": "src/app/core/setting/about/page.tsx",
    "content": "'use client';\n\nimport { Store } from \"lucide-react\"\nimport { SettingAbout } from \"./setting-about\";\n\nexport default function AboutPage() {\n  return <SettingAbout id=\"about\" icon={<Store />} />\n}\n"
  },
  {
    "path": "src/app/core/setting/about/setting-about.tsx",
    "content": "'use client';\nimport { SettingType } from \"../components/setting-base\";\nimport { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from \"@/components/ui/item\";\nimport { useTranslations } from 'next-intl';\nimport Updater from \"./updater\";\nimport { Bug, DownloadIcon, Github, HomeIcon, MessageSquare, SettingsIcon } from \"lucide-react\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function SettingAbout({id, icon}: {id: string, icon?: React.ReactNode}) {\n  const t = useTranslations('settings.about');\n\n  const items = [\n    {\n      url: \"https://notegen.top/\",\n      title: t('items.home.title'),\n      desc: t('items.home.desc'),\n      icon: <HomeIcon className=\"size-4\" />,\n      buttonName: t('items.home.buttonName')\n    },\n    {\n      url: \"https://notegen.top/en/docs/settings/sync\",\n      title: t('items.guide.title'),\n      desc: t('items.guide.desc'),\n      icon: <SettingsIcon className=\"size-4\" />,\n      buttonName: t('items.guide.buttonName')\n    },\n    {\n      url: \"https://github.com/codexu/note-gen\",\n      title: t('items.github.title'),\n      desc: t('items.github.desc'),\n      icon: <Github className=\"size-4\" />,\n      buttonName: t('items.github.buttonName')\n    },\n    {\n      url: \"https://github.com/codexu/note-gen/releases\",\n      title: t('items.releases.title'),\n      desc: t('items.releases.desc'),\n      icon: <DownloadIcon className=\"size-4\" />,\n      buttonName: t('items.releases.buttonName')\n    },\n    {\n      url: \"https://github.com/codexu/note-gen/issues\",\n      title: t('items.issues.title'),\n      desc: t('items.issues.desc'),\n      icon: <Bug className=\"size-4\" />,\n      buttonName: t('items.issues.buttonName')\n    },\n    {\n      url: \"https://github.com/codexu/note-gen/discussions\",\n      title: t('items.discussions.title'),\n      desc: t('items.discussions.desc'),\n      icon: <MessageSquare className=\"size-4\" />,\n      buttonName: t('items.discussions.buttonName')\n    }\n  ]\n\n  return (\n    <SettingType id={id} icon={icon} title={t('title')} desc={t('desc')}>\n      <Updater />\n      <ItemGroup className=\"gap-4 pt-8\">\n        {\n          items.map(item => <AboutItem key={item.url} {...item} />)\n        }\n      </ItemGroup>\n    </SettingType>\n  )\n}\n\nfunction AboutItem({url, title, desc, icon, buttonName}: {url: string, title: string, desc?: string, icon?: React.ReactNode, buttonName?: string}) {\n  const openInBrowser = () => {\n    open(url);\n  }\n  return <Item variant=\"outline\">\n    <ItemMedia variant=\"icon\">{icon}</ItemMedia>\n    <ItemContent>\n      <ItemTitle>{title}</ItemTitle>\n      {desc && <ItemDescription>{desc}</ItemDescription>}\n    </ItemContent>\n    <ItemActions>\n      <Button variant=\"outline\" onClick={openInBrowser}>{buttonName}</Button>\n    </ItemActions>\n  </Item>\n}\n"
  },
  {
    "path": "src/app/core/setting/about/updater.tsx",
    "content": "import { relaunch } from '@tauri-apps/plugin-process';\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { useTranslations } from 'next-intl';\nimport useSettingStore from '@/stores/setting';\nimport useUpdateStore from '@/stores/update';\nimport Image from 'next/image';\nimport { toast } from '@/hooks/use-toast';\nimport { Badge } from '@/components/ui/badge';\nimport { ArrowBigRightDash, Link, Loader2 } from 'lucide-react';\nimport { getRelease } from '@/lib/sync/github';\nimport { open } from '@tauri-apps/plugin-shell';\nimport { isMobileDevice } from '@/lib/check';\n\nexport default function Updater() {\n    const t = useTranslations('settings.about');\n    const [checking, setChecking] = useState(false);\n    const [loading, setLoading] = useState(false);\n    const { version } = useSettingStore();\n    const { update, checkForUpdates, ignoreCurrentVersion } = useUpdateStore();\n    const [latestBody, setLatestBody] = useState(null);\n    const [isMobile, setIsMobile] = useState(false);\n\n    async function checkUpdate() {\n      setChecking(true);\n      try {\n        await checkForUpdates();\n        getRelease().then((release) => {\n          if (release) {\n            setLatestBody(release.body)\n          }\n        })\n      } catch (error) {\n        toast({\n          title: t('checkError'),\n          description: error as string,\n          variant: 'destructive'\n        });\n      } finally {\n        setChecking(false);\n      }\n    }\n    \n    async function checkVersion() {\n      setLoading(true);\n      if (update) {\n        try {\n          await update.downloadAndInstall();\n        } catch (error) {\n          toast({\n            title: t('checkError'),\n            description: error as string,\n            variant: 'destructive'\n          });\n        }\n        await relaunch();\n      } else {\n        toast({\n          title: t('checkError'),\n          description: t('noUpdate'),\n          variant: 'default'\n        });\n        setLoading(false);\n      }\n    }\n\n    function openRelease() {\n      open('https://github.com/codexu/note-gen/releases');\n    }\n\n    async function handleIgnoreVersion() {\n      await ignoreCurrentVersion();\n      toast({\n        title: t('ignoreVersionSuccess'),\n        variant: 'default'\n      });\n    }\n\n    useEffect(() => {\n      const _isMobile = isMobileDevice();\n      setIsMobile(_isMobile);\n      if (!_isMobile) {\n        checkUpdate();\n      }\n    }, []);\n\n    return (\n      <div className=\"flex flex-col gap-4 w-full\">\n        <div className=\"flex flex-col md:flex-row md:justify-between w-full md:items-center gap-4 md:gap-0\">\n          <div className=\"flex items-center gap-4\">\n            <div className=\"size-24\">\n              <Image src=\"/app-icon.png\" alt=\"logo\" className=\"size-24 dark:invert\" width={0} height={0} />\n            </div>\n            <div className=\"h-24 flex-1 flex flex-col justify-between\">\n              <span className=\"text-xl md:text-2xl font-bold flex items-center gap-2\">NoteGen</span>\n              <span className='text-sm md:text-base'>\n                {t('desc')}\n              </span>\n              <div className=\"flex items-center gap-2\">\n                <Badge variant=\"outline\">v{version}</Badge>\n                {\n                  update ? (\n                    <>\n                      <ArrowBigRightDash className=\"size-4\" />\n                      <Badge className=\"bg-green-500 text-white\" variant=\"outline\">v{update.version}</Badge>\n                    </>\n                  ) : null\n                }\n              </div>\n            </div>\n          </div>\n          {\n            !isMobile ? (\n              <Button disabled={!update || loading || checking} onClick={checkVersion}>\n                {checking || loading ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : null}\n                {checking ? t('checkUpdate') : update ? t('updateAvailable') : t('noUpdate')}\n              </Button>\n            ) : null\n          }\n        </div>\n        {\n          update && latestBody ? (\n            <div className=\"mt-8 p-4 border rounded-md\">\n              <div className=\"flex items-center gap-2 justify-between mb-4\">\n                <h1 className=\"text-lg font-bold\">NoteGen v{update.version}</h1>\n                <div className=\"flex items-center gap-2\">\n                  <Button size=\"sm\" variant=\"outline\" onClick={handleIgnoreVersion}>\n                    {t('ignoreVersion')}\n                  </Button>\n                  <Button size=\"sm\" variant=\"outline\" onClick={openRelease}>\n                    <Link className='!size-3' />Release\n                  </Button>\n                </div>\n              </div>\n              <p className=\"text-sm text-muted-foreground whitespace-pre-line\">{latestBody}</p>\n            </div>\n          ) : null\n        }\n      </div>\n    ) \n}"
  },
  {
    "path": "src/app/core/setting/ai/create.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { baseAiConfig } from \"../config\";\nimport { useState } from \"react\";\nimport { useTranslations } from \"next-intl\";\nimport { BotMessageSquare, ChevronRight, Plus, Settings } from \"lucide-react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { AiConfig } from \"../config\";\nimport * as React from \"react\"\nimport { v4 } from 'uuid';\nimport { AvatarImage } from \"@/components/ui/avatar\";\nimport { Avatar } from \"@radix-ui/react-avatar\";\nimport useSettingStore from \"@/stores/setting\";\nimport { useLocalStorage } from \"react-use\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\nimport { isMobileDevice as checkIsMobileDevice } from \"@/lib/check\";\n\ninterface CreateConfigProps {\n  hasCustomModels?: boolean;\n  onConfigCreated?: (configId: string) => void;\n}\n\n// 独立的创建配置对话框组件\nfunction CreateConfigDialog({ open, setOpen, onConfigCreated }: { open: boolean; setOpen: (open: boolean) => void; onConfigCreated?: (configId: string) => void }) {\n  const t = useTranslations('settings.ai');\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n  const { setAiModelList } = useSettingStore()\n  const [, setSelectedAiConfig] = useLocalStorage<string>('ai-config-selected', '')\n\n  const customModel: AiConfig = {\n    key: '',\n    baseURL: '',\n    title: t('custom'),\n    temperature: 0.7,\n    topP: 1.0,\n  }\n\n  // 添加自定义模型\n  async function addCustomModelHandler(model: AiConfig) {\n    const store = await Store.load('store.json');\n    let aiModelList = await store.get<AiConfig[]>('aiModelList')\n    if (!aiModelList) {\n      await store.set('aiModelList', [])\n      aiModelList = []\n    }\n    const id = v4()\n    const newModel: AiConfig = {\n      ...model,\n      key: id,\n      modelType: 'chat'\n    }\n    const updatedList = [newModel, ...aiModelList]\n    setAiModelList(updatedList)\n    \n    // 设置新建的配置为当前选中的配置\n    setSelectedAiConfig(id)\n    \n    await store.set('aiModelList', updatedList)\n    await store.save()\n    \n    // 通知父组件配置已创建\n    if (onConfigCreated) {\n      onConfigCreated(id)\n    }\n    \n    setOpen(false)\n  }\n\n  const content = (\n    <>\n      <ProviderItem item={customModel} onClick={() => addCustomModelHandler(customModel)}/>\n      <p className=\"text-xs text-muted-foreground\">供应商模板</p>\n      <div className=\"overflow-y-auto grid grid-cols-1 md:grid-cols-2 gap-2\">\n        {\n          baseAiConfig.map((item, index) => (\n            <ProviderItem key={index} item={item} onClick={() => addCustomModelHandler(item)}/>\n          ))\n        }\n      </div>\n    </>\n  )\n\n  if (isMobile) {\n    return (\n      <Drawer open={open} onOpenChange={setOpen}>\n        <DrawerContent className=\"max-h-[85vh]\">\n          <DrawerHeader>\n            <DrawerTitle>{t('create')}</DrawerTitle>\n            <DrawerDescription>\n              {t('createDesc')}\n            </DrawerDescription>\n          </DrawerHeader>\n          <div className=\"space-y-3 px-4 pb-6 overflow-y-auto\">\n            {content}\n          </div>\n        </DrawerContent>\n      </Drawer>\n    )\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"max-w-[650px]\">\n        <DialogHeader>\n          <DialogTitle>{t('create')}</DialogTitle>\n          <DialogDescription>\n            {t('createDesc')}\n          </DialogDescription>\n        </DialogHeader>\n        {content}\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport default function CreateConfig({ hasCustomModels = false, onConfigCreated }: CreateConfigProps) {\n  const t = useTranslations('settings.ai');\n  const [open, setOpen] = useState(false)\n\n\n  if (hasCustomModels) {\n    // 有自定义模型时，只显示按钮\n    return (\n      <div className=\"mb-6\">\n        <Button onClick={() => setOpen(true)}>\n          <Plus />{t('create')}\n        </Button>\n        <CreateConfigDialog open={open} setOpen={setOpen} onConfigCreated={onConfigCreated} />\n      </div>\n    )\n  }\n\n  // 没有自定义模型时，显示完整的Card\n  return (\n    <Card className=\"mb-6\">\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          <Settings className=\"h-5 w-5\" />\n          {t('createSection.title')}\n        </CardTitle>\n        <CardDescription>\n          {t('createSection.descWithoutModels')}\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Button onClick={() => setOpen(true)}>\n          <Plus />{t('create')}\n        </Button>\n        <CreateConfigDialog open={open} setOpen={setOpen} onConfigCreated={onConfigCreated} />\n      </CardContent>\n    </Card>\n  )\n}\n\nfunction ProviderItem({item, onClick}: {item: AiConfig, onClick: (model: AiConfig) => void}) {\n  return (\n    <div onClick={() => onClick(item)} className=\"h-12 flex items-center rounded-md gap-2 justify-between p-2 border hover:text-third hover:bg-third-foreground cursor-pointer\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"size-6 bg-white rounded flex items-center justify-center\">\n            {item.icon ? \n              <Avatar>\n                <AvatarImage className=\"size-4\" src={item.icon || ''} />\n              </Avatar>\n            : <BotMessageSquare className=\"size-4 text-primary\" />}\n          </div>\n          <p className=\"text-sm font-bold\">{item.title}</p>\n        </div>\n        <ChevronRight className=\"size-4\" />\n      </div>\n    )\n}\n"
  },
  {
    "path": "src/app/core/setting/ai/default-models.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl';\nimport { useTheme } from 'next-themes';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Gem, Move3D, Eye, MessageSquare } from \"lucide-react\";\nimport Image from 'next/image';\nimport { open } from '@tauri-apps/plugin-shell'\n\nexport default function DefaultModelsSection() {\n  const t = useTranslations('settings.ai.defaultModels');\n  const { theme, systemTheme } = useTheme();\n  \n  // 确定当前主题\n  const currentTheme = theme === 'system' ? systemTheme : theme;\n  const isDark = currentTheme === 'dark';\n  \n  // SiliconFlow 图片URL\n  const siliconFlowImageUrl = isDark \n    ? 'https://s2.loli.net/2025/09/10/KWPOA5XhIGmYTV9.png'\n    : 'https://s2.loli.net/2025/09/10/gVhlriQ81PJabSY.png';\n\n  const models = [\n    {\n      name: t('chatModel.name'),\n      type: t('chatModel.type'),\n      desc: t('chatModel.desc'),\n      icon: <MessageSquare className=\"h-5 w-5\" />,\n      color: 'bg-blue-500'\n    },\n    {\n      name: t('embeddingModel.name'),\n      type: t('embeddingModel.type'),\n      desc: t('embeddingModel.desc'),\n      icon: <Move3D className=\"h-5 w-5\" />,\n      color: 'bg-green-500'\n    },\n    {\n      name: t('visionModel.name'),\n      type: t('visionModel.type'),\n      desc: t('visionModel.desc'),\n      icon: <Eye className=\"h-5 w-5\" />,\n      color: 'bg-purple-500'\n    }\n  ];\n\n  function openInBrowser() {\n    open('https://cloud.siliconflow.cn/i/O2ciJeZw')\n  }\n\n  return (\n    <Card className=\"mb-6 relative\">\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          <Gem className=\"h-5 w-5\" />\n          {t('title')}\n        </CardTitle>\n        <CardDescription>\n          {t('desc')}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 模型列表 */}\n        <div className=\"grid gap-4 md:grid-cols-1 lg:grid-cols-3\">\n          {models.map((model, index) => (\n            <div key={index} className=\"flex items-start gap-3 p-4 rounded-lg border bg-card\">\n              <div className={`p-2 rounded-md ${model.color} text-white`}>\n                {model.icon}\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <div className=\"flex items-center gap-2 mb-1\">\n                  <Badge variant=\"secondary\" className=\"text-xs\">\n                    {model.type}\n                  </Badge>\n                </div>\n                <h4 className=\"font-medium text-sm mb-1 truncate\">\n                  {model.name}\n                </h4>\n                <p className=\"text-xs text-muted-foreground\">\n                  {model.desc}\n                </p>\n              </div>\n            </div>\n          ))}\n        </div>\n        <Image\n          src={siliconFlowImageUrl}\n          alt=\"SiliconFlow\"\n          width={240}\n          height={60}\n          className=\"h-10 w-auto object-contain cursor-pointer hover:shadow\"\n          unoptimized\n          onClick={openInBrowser}\n        />\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/app/core/setting/ai/model-card.tsx",
    "content": "'use client'\nimport { \n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Slider } from \"@/components/ui/slider\"\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\"\nimport { Label } from \"@/components/ui/label\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Trash2, CircleCheck, CircleX, LoaderCircle } from \"lucide-react\"\nimport { ModelConfig, ModelType, AiConfig } from \"../config\"\nimport { useTranslations } from 'next-intl'\nimport ModelSelect from \"./modelSelect\"\nimport { useState, useRef } from \"react\"\nimport { createOpenAIClient } from \"@/lib/ai/utils\"\nimport { toast } from \"@/hooks/use-toast\"\nimport { fetch } from \"@tauri-apps/plugin-http\"\n\ninterface ModelCardProps {\n  modelConfig: ModelConfig\n  aiConfig: AiConfig\n  onUpdate: (modelId: string, field: keyof ModelConfig, value: any) => void\n  onDelete: (modelId: string) => void\n}\n\nexport default function ModelCard({ modelConfig, aiConfig, onUpdate, onDelete }: ModelCardProps) {\n  const t = useTranslations('settings.ai')\n  const [checkState, setCheckState] = useState<'ok' | 'error' | 'checking' | 'init'>('init')\n  const abortControllerRef = useRef<AbortController | null>(null)\n\n  const handleCheck = async () => {\n    // 取消之前的请求\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n    }\n    \n    setCheckState('checking')\n    abortControllerRef.current = new AbortController()\n    \n    try {\n      const aiStatus = await checkModelStatus(modelConfig, aiConfig, abortControllerRef.current.signal)\n      if (aiStatus) {\n        setCheckState('ok')\n        toast({\n          description: t('connectionSuccess'),\n          className: 'border-green-500 bg-green-50 text-green-800'\n        })\n      } else {\n        setCheckState('error')\n      }\n    } catch (error) {\n      if (error instanceof Error && error.name === 'AbortError') {\n        return\n      }\n      setCheckState('error')\n    }\n  }\n\n  const checkModelStatus = async (model: ModelConfig, aiConfig: AiConfig, signal?: AbortSignal) => {\n    try {\n      if (!model.model || !aiConfig.baseURL) return false\n\n      const fullAiConfig: AiConfig = {\n        ...aiConfig,\n        model: model.model,\n        modelType: model.modelType,\n        temperature: model.temperature,\n        topP: model.topP,\n        voice: model.voice,\n        enableStream: model.enableStream\n      }\n\n      switch (model.modelType) {\n        case 'rerank':\n          const query = 'Apple'\n          const documents = [\"apple\",\"banana\",\"fruit\",\"vegetable\"]\n          const response = await fetch(aiConfig.baseURL + '/rerank', {\n            method: 'POST',\n            headers: {\n              'Content-Type': 'application/json',\n              'Authorization': `Bearer ${aiConfig.apiKey}`,\n              'Origin': \"\",\n              ...(aiConfig.customHeaders || {})\n            },\n            body: JSON.stringify({\n              model: model.model,\n              query,\n              documents\n            }),\n            signal\n          })\n          if (!response.ok) {\n            throw new Error(`重排序请求失败: ${response.status} ${response.statusText}`)\n          }\n          const rerankData = await response.json()\n          if (!rerankData || !rerankData.results) {\n            throw new Error('重排序结果格式不正确')\n          }\n          return true\n\n        case 'embedding':\n          const testText = '测试文本'\n          const embeddingData = await fetch(aiConfig.baseURL + '/embeddings', {\n            method: 'POST',\n            headers: {\n              'Content-Type': 'application/json',\n              'Authorization': `Bearer ${aiConfig.apiKey}`,\n              'Origin': \"\",\n              ...(aiConfig.customHeaders || {})\n            },\n            body: JSON.stringify({\n              model: model.model,\n              input: testText,\n              encoding_format: 'float'\n            }),\n            signal\n          })\n          if (!embeddingData.ok) {\n            throw new Error(`嵌入请求失败: ${embeddingData.status} ${embeddingData.statusText}`)\n          }\n          const embeddingDataJson = await embeddingData.json()\n          if (!embeddingDataJson || !embeddingDataJson.data || !embeddingDataJson.data[0] || !embeddingDataJson.data[0].embedding) {\n            throw new Error('嵌入结果格式不正确')\n          }\n          return true\n\n        case 'tts':\n          const testAudioText = '测试音频生成'\n          const ttsResponse = await fetch(aiConfig.baseURL + '/audio/speech', {\n            method: 'POST',\n            headers: {\n              'Content-Type': 'application/json',\n              'Authorization': `Bearer ${aiConfig.apiKey}`,\n              'Origin': \"\",\n              ...(aiConfig.customHeaders || {})\n            },\n            body: JSON.stringify({\n              model: model.model,\n              input: testAudioText,\n              voice: model.voice || 'alloy'\n            }),\n            signal\n          })\n          if (!ttsResponse.ok) {\n            throw new Error(`TTS请求失败: ${ttsResponse.status} ${ttsResponse.statusText}`)\n          }\n          const ttsContentType = ttsResponse.headers.get('content-type')\n          if (!ttsContentType || !ttsContentType.includes('audio')) {\n            throw new Error('TTS模型返回格式不正确')\n          }\n          return true\n\n        case 'stt':\n          // STT 测试：只检查 API 端点连通性\n          // 发送一个简单的测试请求，不验证具体返回内容\n          // 因为空音频文件可能导致服务器ffmpeg解析失败，但这不代表模型不可用\n          const testAudioBlob = new Blob([new Uint8Array(100)], { type: 'audio/webm' })\n          const sttFormData = new FormData()\n          sttFormData.append('file', testAudioBlob, 'test.webm')\n          sttFormData.append('model', model.model)\n          \n          const sttResponse = await fetch(aiConfig.baseURL + '/audio/transcriptions', {\n            method: 'POST',\n            headers: {\n              'Authorization': `Bearer ${aiConfig.apiKey}`,\n              ...(aiConfig.customHeaders || {})\n            },\n            body: sttFormData,\n            signal\n          })\n          \n          // 对于STT，只要API响应了（即使是400错误），就认为连接成功\n          // 400错误通常是因为测试音频无效，但说明API端点是可达的\n          if (sttResponse.status === 401 || sttResponse.status === 403) {\n            // 认证错误才是真正的失败\n            throw new Error(`STT认证失败 (${sttResponse.status})`)\n          }\n          \n          // 其他情况（包括200成功和400音频解析失败）都认为连接成功\n          return true\n\n        default:\n          const openai = await createOpenAIClient(fullAiConfig)\n          await openai.chat.completions.create({\n            model: model.model,\n            messages: [{\n              role: 'user' as const,\n              content: 'Hello'\n            }],\n            stream: model.enableStream !== false,\n          })\n          return true\n      }\n    } catch (error) {\n      toast({\n        description: error instanceof Error ? error.message : 'Error',\n        variant: 'destructive'\n      })\n      return false\n    }\n  }\n\n  const renderCheckIcon = () => {\n    switch (checkState) {\n      case 'ok':\n        return <CircleCheck className=\"text-green-500 size-4\" />\n      case 'error':\n        return <CircleX className=\"text-red-500 size-4\" />\n      case 'checking':\n        return <LoaderCircle className=\"animate-spin size-4\" />\n      default:\n        return null\n    }\n  }\n\n  return (\n    <AccordionItem value={modelConfig.id} className=\"border rounded-lg\">\n      <div className=\"flex items-center justify-between flex-wrap\">\n        <div className=\"flex-1\">\n          <AccordionTrigger className=\"w-full px-4 py-4 hover:no-underline\">\n            <div className=\"flex items-center\">\n              <span className=\"text-base font-semibold\">\n                {modelConfig.model || t('newModel')}\n              </span>\n              <Badge variant=\"secondary\" className=\"ml-2\">\n                {t(`modelType.${modelConfig.modelType}`)}\n              </Badge>\n            </div>\n          </AccordionTrigger>\n        </div>\n        <div className=\"flex items-center justify-end gap-2 p-2\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleCheck}\n            disabled={!modelConfig.model || checkState === 'checking'}\n          >\n            {renderCheckIcon()}\n            {t('checkConnection')}\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => onDelete(modelConfig.id)}\n          >\n            <Trash2 className=\"size-4\" />\n          </Button>\n        </div>\n      </div>      \n      <AccordionContent className=\"px-4 pb-4 space-y-4\">\n        {/* 模型选择 */}\n        <div className=\"space-y-2\">\n          <Label>{t('model')}</Label>\n          <ModelSelect\n            model={modelConfig.model}\n            setModel={(model) => onUpdate(modelConfig.id, 'model', model)}\n            aiConfig={aiConfig}\n          />\n        </div>\n\n        {/* 模型类型 */}\n        <div className=\"space-y-2\">\n          <Label>{t('modelType.title')}</Label>\n          <RadioGroup\n            value={modelConfig.modelType}\n            onValueChange={(value) => onUpdate(modelConfig.id, 'modelType', value as ModelType)}\n            className=\"flex flex-wrap gap-4\"\n          >\n            <div className=\"flex items-center space-x-2\">\n              <RadioGroupItem value=\"chat\" id={`chat-${modelConfig.id}`} />\n              <Label htmlFor={`chat-${modelConfig.id}`}>{t('modelType.chat')}</Label>\n            </div>\n            <div className=\"flex items-center space-x-2\">\n              <RadioGroupItem value=\"tts\" id={`tts-${modelConfig.id}`} />\n              <Label htmlFor={`tts-${modelConfig.id}`}>{t('modelType.tts')}</Label>\n            </div>\n            <div className=\"flex items-center space-x-2\">\n              <RadioGroupItem value=\"stt\" id={`stt-${modelConfig.id}`} />\n              <Label htmlFor={`stt-${modelConfig.id}`}>{t('modelType.stt')}</Label>\n            </div>\n            <div className=\"flex items-center space-x-2\">\n              <RadioGroupItem value=\"embedding\" id={`embedding-${modelConfig.id}`} />\n              <Label htmlFor={`embedding-${modelConfig.id}`}>{t('modelType.embedding')}</Label>\n            </div>\n            <div className=\"flex items-center space-x-2\">\n              <RadioGroupItem value=\"rerank\" id={`rerank-${modelConfig.id}`} />\n              <Label htmlFor={`rerank-${modelConfig.id}`}>{t('modelType.rerank')}</Label>\n            </div>\n          </RadioGroup>\n        </div>\n\n        {/* Chat模型的特殊配置 */}\n        {modelConfig.modelType === 'chat' && (\n          <>\n            <div className=\"space-y-2\">\n              <Label>Temperature</Label>\n              <div className=\"flex gap-2 items-center\">\n                <Slider\n                  className=\"flex-1\"\n                  value={[modelConfig.temperature || 0.7]}\n                  max={2}\n                  step={0.01}\n                  onValueChange={(value) => onUpdate(modelConfig.id, 'temperature', value[0])}\n                />\n                <span className=\"text-sm text-muted-foreground w-12\">\n                  {(modelConfig.temperature || 0.7).toFixed(2)}\n                </span>\n              </div>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label>Top P</Label>\n              <div className=\"flex gap-2 items-center\">\n                <Slider\n                  className=\"flex-1\"\n                  value={[modelConfig.topP || 1.0]}\n                  max={1}\n                  min={0}\n                  step={0.01}\n                  onValueChange={(value) => onUpdate(modelConfig.id, 'topP', value[0])}\n                />\n                <span className=\"text-sm text-muted-foreground w-12\">\n                  {(modelConfig.topP || 1.0).toFixed(2)}\n                </span>\n              </div>\n            </div>\n\n            <div className=\"flex items-center justify-between\">\n              <div className=\"space-y-0.5\">\n                <Label>{t('enableStream')}</Label>\n                <div className=\"text-sm text-muted-foreground\">\n                  {t('enableStreamDesc')}\n                </div>\n              </div>\n              <Switch\n                checked={modelConfig.enableStream !== false}\n                onCheckedChange={(checked) => onUpdate(modelConfig.id, 'enableStream', checked)}\n              />\n            </div>\n          </>\n        )}\n\n        {/* TTS模型的特殊配置 */}\n        {modelConfig.modelType === 'tts' && (\n          <div className=\"space-y-2\">\n            <Label>{t('voice')}</Label>\n            <Input\n              value={modelConfig.voice || ''}\n              onChange={(e) => onUpdate(modelConfig.id, 'voice', e.target.value)}\n              placeholder={t('voicePlaceholder')}\n            />\n          </div>\n        )}\n      </AccordionContent>\n    </AccordionItem>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/ai/modelSelect.tsx",
    "content": "import { useEffect, useState, useRef } from \"react\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Input } from \"@/components/ui/input\";\nimport { createOpenAIClient } from \"@/lib/ai/utils\";\nimport OpenAI from \"openai\";\nimport { Check, ChevronsUpDown, Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { AiConfig } from \"../config\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport emitter from \"@/lib/emitter\";\n\nexport default function ModelSelect(\n  { model, setModel, aiConfig }:\n  { model: string, setModel?: (model: string) => void, aiConfig?: AiConfig }\n) {\n  const [loading, setLoading] = useState(false)\n  const { currentAi } = useSettingStore()\n  const [list, setList] = useState<OpenAI.Models.Model[]>([])\n  const [open, setOpen] = useState(false)\n  const [inputValue, setInputValue] = useState<string>(\"\") \n  const currentRequestIdRef = useRef<number>(0)\n  \n  // 检查输入的模型是否存在于列表中\n  const modelExists = (value: string) => {\n    return list.some(item => item.id.toLowerCase() === value.toLowerCase());\n  }\n\n  async function initModelList() {\n    const store = await Store.load('store.json')\n    let model: AiConfig | undefined\n    \n    if (aiConfig) {\n      // 如果传入了aiConfig，直接使用\n      model = aiConfig\n    } else {\n      // 否则从store中获取当前AI配置\n      const aiModelList = await store.get<AiConfig[]>('aiModelList')\n      model = aiModelList?.find(item => item.key === currentAi)\n    }\n    \n    if (!model) return\n    \n    const requestId = ++currentRequestIdRef.current\n    const models = await getModels(model, requestId)\n    \n    if (requestId !== currentRequestIdRef.current) return\n    \n    if (!models) return\n    setList(models)\n    \n    // 如果没有传入aiConfig，则从store中设置model值\n    if (!aiConfig && setModel) {\n      const store = await Store.load('store.json')\n      const aiModelList = await store.get<AiConfig[]>('aiModelList')\n      const modelConfig = aiModelList?.find((item: AiConfig) => item.key === currentAi)\n      if (modelConfig) {\n        setModel(modelConfig.model || '')\n      }\n    }\n  }\n\n  // 获取模型列表\n  async function getModels(model: AiConfig, requestId: number) {\n    try {\n      setLoading(true)\n      if (requestId !== currentRequestIdRef.current) return null;\n      \n      const openai = await createOpenAIClient(model)\n      \n      if (requestId !== currentRequestIdRef.current) return null;\n      \n      const models = await openai.models.list()\n      \n      if (requestId !== currentRequestIdRef.current) return null;\n      \n      const uniqueModels = models.data.filter((model, index) => models.data.findIndex(m => m.id === model.id) === index)\n      return uniqueModels\n    } catch {\n      return []\n    } finally {\n      if (requestId === currentRequestIdRef.current) {\n        setLoading(false)\n      }\n    }\n  }\n\n  async function syncModelList(value: string) {\n    // 使用传递的setModel回调来更新模型\n    if (setModel) {\n      setModel(value)\n    }\n  }\n\n  const handleSelectOrCreate = (value: string) => {\n    setOpen(false)\n    syncModelList(value)\n  }\n\n  const handleInputChange = (value: string) => {\n    // 只更新输入值，不做其他处理\n    setInputValue(value)\n  }\n\n  const handleCustomValue = () => {\n    if (inputValue.trim()) {\n      setOpen(false)\n      syncModelList(inputValue)\n    }\n  }\n\n  useEffect(() => {\n    emitter.on('getSettingModelList', () => {\n      setTimeout(() => {\n        initModelList()\n      }, 500)\n    })\n    return () => {\n      emitter.off('getSettingModelList')\n    }\n  }, [])\n\n  // 只在初始化和模型变化时设置输入值\n  useEffect(() => {\n    if (model) {\n      setInputValue(model)\n    }\n  }, [model])\n\n  useEffect(() => {\n    setList([])\n    setInputValue('')\n    // Increment the request ID to cancel any in-progress requests\n    currentRequestIdRef.current++;\n    initModelList()\n  }, [currentAi])\n  \n  return (<>\n    {list.length ? (\n        <Popover open={open} onOpenChange={setOpen}>\n          <PopoverTrigger className=\"w-full\" asChild>\n            <Button\n              variant=\"outline\"\n              role=\"combobox\"\n              aria-expanded={open}\n              className=\"mt-2 w-full justify-between\"\n            >\n              {model\n                ? list.find((item) => item.id === model)?.id || model\n                : \"Select model\"}\n              <ChevronsUpDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n            </Button>\n          </PopoverTrigger>\n          <PopoverContent className=\"w-[300px] p-0\" align=\"start\">\n            <Command className=\"w-full\">\n              <CommandInput\n                placeholder=\"Search model...\"\n                value={inputValue}\n                onValueChange={handleInputChange}\n              />\n              <CommandList>\n                <CommandEmpty>\n                  {inputValue.trim() !== \"\" && !modelExists(inputValue) ? (\n                    <div className=\"py-6 text-center text-sm\">\n                      <Button \n                        variant=\"ghost\" \n                        className=\"text-sm w-full\" \n                        onClick={handleCustomValue}\n                      >\n                        Use &quot;{inputValue}&quot;\n                      </Button>\n                    </div>\n                  ) : (\n                    <div className=\"py-6 text-center text-sm\">No model found.</div>\n                  )}\n                </CommandEmpty>\n                <CommandGroup>\n                  {list.map((item) => (\n                    <CommandItem\n                      key={item.id}\n                      value={item.id}\n                      onSelect={() => handleSelectOrCreate(item.id)}\n                      className=\"text-sm py-2 cursor-pointer\"\n                    >\n                      <Check\n                        className={cn(\n                          \"mr-2 h-4 w-4\",\n                          model === item.id ? \"opacity-100\" : \"opacity-0\"\n                        )}\n                      />\n                      {item.id}\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n      ) :\n        <div className=\"flex gap-2 flex-col\">\n          <Input \n            value={model} \n            onChange={(e) => syncModelList(e.target.value)} \n            className=\"w-full mt-2\" \n            placeholder=\"Input model name\" \n          />\n          {loading && \n            <div className=\"flex gap-2 items-center text-xs text-muted-foreground\">\n              <Loader2 className=\"size-4 animate-spin\" />\n              <p className=\"line-clamp-1 flex-1\">Loading models...</p>\n            </div>\n          }\n        </div>\n      }\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/ai/page.tsx",
    "content": "'use client'\nimport { useState, useEffect } from \"react\";\nimport { useTranslations } from 'next-intl';\nimport { useLocalStorage } from 'react-use';\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { v4 } from 'uuid';\nimport { confirm } from '@tauri-apps/plugin-dialog';\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n} from \"@/components/ui/select\"\nimport {\n  Accordion,\n} from \"@/components/ui/accordion\"\nimport Image from \"next/image\";\n\nimport { SettingType, FormItem } from \"../components/setting-base\";\nimport { AiConfig, ModelConfig, baseAiConfig } from \"../config\";\nimport useSettingStore from \"@/stores/setting\";\nimport { noteGenModelKeys } from \"@/app/model-config\";\nimport { BotMessageSquare, Copy, Eye, EyeOff, Plus, Trash2, X } from \"lucide-react\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport DefaultModelsSection from \"./default-models\";\nimport ModelCard from \"./model-card\";\nimport CreateConfig from \"./create\";\n\n\nexport default function AiPage() {\n  const t = useTranslations('settings.ai');\n  const {\n    aiModelList,\n    setAiModelList\n  } = useSettingStore()\n\n  // 过滤掉默认模型，只显示用户自定义模型\n  const userCustomModels = aiModelList.filter(model => !noteGenModelKeys.includes(model.key) && model.title !== 'NoteGen Limited')\n  const [apiKeyVisible, setApiKeyVisible] = useState<boolean>(false)\n  const [headerPairs, setHeaderPairs] = useState<Array<{key: string, value: string, id: string}>>([])\n  const [expandedModels, setExpandedModels] = useState<string[]>([])\n  \n  // 使用 useLocalStorage 记录当前选择的AI配置\n  const [selectedAiConfig, setSelectedAiConfig] = useLocalStorage<string>('ai-config-selected', '')\n  \n  // 当前选中的AI配置\n  const currentConfig = userCustomModels.find(model => model.key === selectedAiConfig)\n  \n  const parseHeadersToKeyValue = (headers: Record<string, string> = {}) => {\n    return Object.entries(headers).map(([key, value]) => ({\n      key, value: String(value), id: Math.random().toString(36).substr(2, 9)\n    }))\n  }\n\n  const convertKeyValueToJson = (pairs: Array<{key: string, value: string}>) => {\n    const obj: Record<string, string> = {}\n    pairs.forEach(pair => { if (pair.key.trim()) obj[pair.key.trim()] = pair.value })\n    return obj\n  }\n\n  // 添加新模型\n  const addNewModel = async () => {\n    if (!currentConfig) return\n    \n    const newModelId = v4()\n    const newModel: ModelConfig = {\n      id: newModelId,\n      model: '',\n      modelType: 'chat',\n      temperature: 0.7,\n      topP: 1.0,\n      enableStream: true\n    }\n    \n    const updatedConfig = {\n      ...currentConfig,\n      models: [...(currentConfig.models || []), newModel]\n    }\n    \n    await updateAiConfig(updatedConfig)\n    \n    // 自动展开新创建的模型\n    setExpandedModels(prev => [...prev, newModelId])\n  }\n\n  // 删除模型\n  const deleteModel = async (modelId: string) => {\n    if (!currentConfig) return\n    \n    const confirmed = await confirm('确定要删除这个模型吗？')\n    if (!confirmed) return\n    \n    const updatedConfig = {\n      ...currentConfig,\n      models: (currentConfig.models || []).filter(m => m.id !== modelId)\n    }\n    \n    await updateAiConfig(updatedConfig)\n    \n    // 从展开列表中移除被删除的模型\n    setExpandedModels(prev => prev.filter(id => id !== modelId))\n  }\n\n  // 更新模型配置\n  const updateModelConfig = async (modelId: string, field: keyof ModelConfig, value: any) => {\n    if (!currentConfig) return\n    \n    const updatedModels = (currentConfig.models || []).map(model => \n      model.id === modelId ? { ...model, [field]: value } : model\n    )\n    \n    const updatedConfig = {\n      ...currentConfig,\n      models: updatedModels\n    }\n    \n    await updateAiConfig(updatedConfig)\n  }\n\n  // 更新AI配置到store\n  const updateAiConfig = async (config: AiConfig) => {\n    const store = await Store.load('store.json')\n    const aiModelList = await store.get<AiConfig[]>('aiModelList') || []\n    const index = aiModelList.findIndex(item => item.key === config.key)\n    \n    if (index >= 0) {\n      aiModelList[index] = config\n      await store.set('aiModelList', aiModelList)\n      setAiModelList(aiModelList)\n    }\n  }\n\n  // 复制当前配置\n  const copyConfig = async () => {\n    if (!currentConfig) return\n\n    const id = v4()\n    const newConfig: AiConfig = {\n      ...currentConfig,\n      key: id,\n      title: `${currentConfig.title || 'Copy'} (Copy)`,\n      // 复制models数组\n      models: currentConfig.models?.map(model => ({\n        ...model,\n        id: v4() // 给每个模型生成新的ID\n      })) || []\n    }\n\n    const store = await Store.load('store.json')\n    const aiModelList = await store.get<AiConfig[]>('aiModelList') || []\n    const updatedList = [...aiModelList, newConfig]\n    \n    await store.set('aiModelList', updatedList)\n    setAiModelList(updatedList)\n    setSelectedAiConfig(newConfig.key)\n  }\n\n  // 删除当前配置\n  const deleteCurrentConfig = async () => {\n    if (!currentConfig) return\n    \n    // 检查是否是NoteGen默认模型\n    if (noteGenModelKeys.includes(currentConfig.key)) {\n      return // 不能删除默认模型\n    }\n\n    const confirmed = await confirm(t('deleteCustomModelConfirm'))\n    if (!confirmed) return\n\n    const store = await Store.load('store.json')\n    const aiModelList = await store.get<AiConfig[]>('aiModelList') || []\n    const updatedList = aiModelList.filter(item => item.key !== currentConfig.key)\n    \n    await store.set('aiModelList', updatedList)\n    setAiModelList(updatedList)\n\n    // 删除后选择下一个用户自定义模型\n    const remainingUserModels = updatedList.filter(model => !noteGenModelKeys.includes(model.key))\n    if (remainingUserModels.length > 0) {\n      setSelectedAiConfig(remainingUserModels[0].key)\n    } else {\n      setSelectedAiConfig('')\n    }\n  }\n\n\n  // 迁移旧配置到新格式\n  const migrateOldConfig = (config: AiConfig): AiConfig => {\n    // 如果已经有models数组，直接返回\n    if (config.models && config.models.length > 0) {\n      return config\n    }\n    \n    // 如果有旧的model配置，迁移到models数组\n    if (config.model) {\n      const migratedModel: ModelConfig = {\n        id: v4(),\n        model: config.model,\n        modelType: config.modelType || 'chat',\n        temperature: config.temperature,\n        topP: config.topP,\n        voice: config.voice,\n        enableStream: config.enableStream\n      }\n      \n      return {\n        ...config,\n        models: [migratedModel]\n      }\n    }\n    \n    return config\n  }\n\n  // 当选中的配置改变时，更新headers\n  useEffect(() => {\n    if (currentConfig) {\n      setHeaderPairs(parseHeadersToKeyValue(currentConfig.customHeaders))\n    } else {\n      setHeaderPairs([])\n    }\n  }, [currentConfig])\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const aiModelList = await store.get<AiConfig[]>('aiModelList')\n      \n      if (aiModelList) {\n        // 迁移旧配置\n        const migratedList = aiModelList.map(migrateOldConfig)\n        \n        // 检查是否有配置被迁移，如果有则保存\n        const hasChanges = migratedList.some((config, index) => \n          JSON.stringify(config) !== JSON.stringify(aiModelList[index])\n        )\n        \n        if (hasChanges) {\n          await store.set('aiModelList', migratedList)\n          setAiModelList(migratedList)\n        }\n      }\n      \n      // 过滤出用户自定义模型\n      const userModels = aiModelList?.filter(model => !noteGenModelKeys.includes(model.key)) || []\n      \n      // 如果已经有保存的选择，且该配置仍然存在，则使用它\n      if (selectedAiConfig && userModels.find(model => model.key === selectedAiConfig)) {\n        // 已经有保存的选择，不需要做任何事情\n        return\n      } else if (userModels.length > 0) {\n        // 如果没有保存的选择或选择的配置不存在，选择第一个\n        const firstUserModel = userModels[0]\n        setSelectedAiConfig(firstUserModel.key)\n      } else {\n        // 如果没有用户自定义模型，清空选择\n        setSelectedAiConfig('')\n      }\n    }\n    init()\n  }, [])\n\n  return (\n    <SettingType id=\"ai\" icon={<BotMessageSquare />} title={t('title')} desc={t('desc')}>\n      {/* 当没有用户自定义模型时显示默认模型区域 */}\n      {userCustomModels.length === 0 && <DefaultModelsSection />}\n      \n      <CreateConfig \n        hasCustomModels={userCustomModels.length > 0} \n        onConfigCreated={(configId) => {\n          setSelectedAiConfig(configId)\n        }}\n      />\n      \n      {userCustomModels.length > 0 && (\n        <div className=\"space-y-8\">\n          {/* AI配置选择 */}\n          <FormItem title={t('modelConfigTitle')} desc={t('modelConfigDesc')}>\n              <div className=\"flex items-center gap-2 md:flex-row flex-col\">\n                <Select value={selectedAiConfig} onValueChange={setSelectedAiConfig}>\n                  <SelectTrigger className=\"w-full\">\n                    <div className=\"flex items-center gap-2\">\n                      {currentConfig?.title || t('selectConfig')}\n                    </div>\n                  </SelectTrigger>\n                  <SelectContent>\n                    {userCustomModels.map((item) => (\n                      <SelectItem value={item.key} key={item.key}>\n                        {item.title}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n                <div className=\"flex items-center gap-2 md:w-auto w-full\">\n                  <Button \n                    disabled={!currentConfig} \n                    variant=\"outline\" \n                    onClick={copyConfig}\n                  >\n                    <Copy className=\"h-4 w-4 mr-2\" />\n                    {t('copyConfig')}\n                  </Button>\n                  <Button \n                    disabled={!currentConfig || noteGenModelKeys.includes(currentConfig?.key || '')} \n                    variant=\"destructive\" \n                    onClick={deleteCurrentConfig}\n                  >\n                    <Trash2 className=\"h-4 w-4 mr-2\" />\n                    {t('deleteCustomModel')}\n                  </Button>\n                </div>\n              </div>\n          </FormItem>\n\n          {/* 当前配置的基础设置 */}\n          {currentConfig && (\n            <>\n              {/* 供应商模板配置信息显示 */}\n              {baseAiConfig.find(config => config.baseURL === currentConfig.baseURL) && (\n                <FormItem title={t('providerInfo')}>\n                    <div className=\"flex items-center gap-3 p-3 bg-muted/50 rounded-lg\">\n                      {baseAiConfig.find(config => config.baseURL === currentConfig.baseURL)?.icon && (\n                        <Image \n                          src={baseAiConfig.find(config => config.baseURL === currentConfig.baseURL)?.icon || ''} \n                          alt={currentConfig.title}\n                          width={32}\n                          height={32}\n                          className=\"w-8 h-8 rounded\"\n                        />\n                      )}\n                      <div>\n                        <div className=\"font-medium\">{currentConfig.title}</div>\n                        <div className=\"text-sm text-muted-foreground\">{currentConfig.baseURL}</div>\n                      </div>\n                    </div>\n                </FormItem>\n              )}\n\n              {/* 配置名称 - 只有非供应商模板配置才显示 */}\n              {!baseAiConfig.find(config => config.baseURL === currentConfig.baseURL) && (\n                <FormItem title={t('modelTitle')} desc={t('modelTitleDesc')}>\n                    <Input \n                      value={currentConfig.title} \n                      onChange={(e) => updateAiConfig({...currentConfig, title: e.target.value})} \n                    />\n                </FormItem>\n              )}\n\n              {/* BaseURL - 只有非供应商模板配置才显示 */}\n              {!baseAiConfig.find(config => config.baseURL === currentConfig.baseURL) && (\n                <FormItem title=\"BaseURL\" desc={t('modelBaseUrlDesc')}>\n                    <Input \n                      value={currentConfig.baseURL || ''} \n                      onChange={(e) => updateAiConfig({...currentConfig, baseURL: e.target.value})} \n                    />\n                </FormItem>\n              )}\n\n              {/* API Key */}\n              <FormItem title=\"API Key\">\n                  <div className=\"flex gap-2\">\n                    <Input \n                      className=\"flex-1\" \n                      value={currentConfig.apiKey || ''} \n                      type={apiKeyVisible ? 'text' : 'password'} \n                      onChange={(e) => updateAiConfig({...currentConfig, apiKey: e.target.value})} \n                    />\n                    <Button variant=\"outline\" size=\"icon\" onClick={() => setApiKeyVisible(!apiKeyVisible)}>\n                      {apiKeyVisible ? <Eye /> : <EyeOff />}\n                    </Button>\n                    {baseAiConfig.find(item => item.baseURL === currentConfig.baseURL)?.apiKeyUrl && (\n                      <OpenBroswer\n                        type=\"button\"\n                        url={baseAiConfig.find(item => item.baseURL === currentConfig.baseURL)?.apiKeyUrl || ''}\n                        title={t('apiKeyUrl')}\n                      />\n                    )}\n                  </div>\n              </FormItem>\n\n              {/* 自定义Headers */}\n              {!baseAiConfig.find(config => config.baseURL === currentConfig.baseURL) && (\n                <FormItem title={t('customHeaders')} desc={t('customHeadersDesc')}>\n                    <div className=\"space-y-2\">\n                      {headerPairs.map((pair, index) => (\n                        <div key={pair.id} className=\"flex gap-2 items-center\">\n                          <Input\n                            placeholder={t('headerKey')}\n                            value={pair.key}\n                            onChange={(e) => {\n                              const newPairs = [...headerPairs]\n                              newPairs[index].key = e.target.value\n                              setHeaderPairs(newPairs)\n                            }}\n                            onBlur={() => {\n                              const jsonObj = convertKeyValueToJson(headerPairs)\n                              updateAiConfig({...currentConfig, customHeaders: jsonObj})\n                            }}\n                            className=\"flex-1\"\n                          />\n                          <Input\n                            placeholder={t('headerValue')}\n                            value={pair.value}\n                            onChange={(e) => {\n                              const newPairs = [...headerPairs]\n                              newPairs[index].value = e.target.value\n                              setHeaderPairs(newPairs)\n                            }}\n                            onBlur={() => {\n                              const jsonObj = convertKeyValueToJson(headerPairs)\n                              updateAiConfig({...currentConfig, customHeaders: jsonObj})\n                            }}\n                            className=\"flex-1\"\n                          />\n                          <Button\n                            variant=\"outline\"\n                            size=\"icon\"\n                            onClick={() => {\n                              const newPairs = headerPairs.filter((_, i) => i !== index)\n                              setHeaderPairs(newPairs)\n                              updateAiConfig({...currentConfig, customHeaders: convertKeyValueToJson(newPairs)})\n                            }}\n                          >\n                            <X className=\"h-4 w-4\" />\n                          </Button>\n                        </div>\n                      ))}\n                      <Button\n                        variant=\"outline\"\n                        onClick={() => setHeaderPairs([...headerPairs, {\n                          key: '', value: '', id: Math.random().toString(36).substr(2, 9)\n                        }])}\n                        className=\"w-full\"\n                      >\n                        <Plus className=\"h-4 w-4 mr-2\" />\n                        {t('addHeader')}\n                      </Button>\n                    </div>\n                </FormItem>\n              )}\n\n              {/* 模型配置区域 */}\n              <FormItem title={t('models')}>\n                  <div className=\"space-y-4\">\n                    {/* 模型卡片列表 */}\n                    <Accordion \n                      type=\"multiple\" \n                      className=\"space-y-2\"\n                      value={expandedModels}\n                      onValueChange={setExpandedModels}\n                    >\n                      {(currentConfig.models || []).map((modelConfig) => (\n                        <ModelCard\n                          key={modelConfig.id}\n                          modelConfig={modelConfig}\n                          aiConfig={currentConfig}\n                          onUpdate={updateModelConfig}\n                          onDelete={deleteModel}\n                        />\n                      ))}\n                    </Accordion>\n                    {/* 添加模型按钮 */}\n                    <Button onClick={addNewModel} className=\"w-full\">\n                      <Plus className=\"h-4 w-4 mr-2\" />\n                      {t('addModel')}\n                    </Button>\n                  </div>\n              </FormItem>\n            </>\n          )}\n        </div>\n      )}\n    </SettingType>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/audio/page.tsx",
    "content": "'use client';\nimport { SettingType } from \"../components/setting-base\";\nimport { Setting } from \"./setting\";\nimport { Volume2 } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\";\n\nexport default function AudioPage() {\n  const t = useTranslations('settings.audio');\n\n  return <SettingType id=\"audio\" icon={<Volume2 />} title={t('title')} desc={t('desc')}>\n    <Setting />\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/audio/setting.tsx",
    "content": "import { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { ModelSelect } from \"../components/model-select\";\nimport { Gauge, Volume2, Mic } from \"lucide-react\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport type { SpeechMode } from '@/lib/speech/types';\n\nexport function Setting() {\n  const t = useTranslations('settings.audio');\n  const {\n    audioModel,\n    textToSpeechMode,\n    setAiModelList,\n    setTextToSpeechMode,\n  } = useSettingStore();\n  const [speed, setSpeed] = useState(1);\n  const modeOptions: Array<{ value: SpeechMode; label: string }> = [\n    { value: 'auto', label: t('mode.auto') },\n    { value: 'local', label: t('mode.local') },\n    { value: 'model', label: t('mode.model') },\n  ];\n\n  // 加载TTS语速设置\n  useEffect(() => {\n    async function loadSpeed() {\n      if (!audioModel) return;\n      const store = await Store.load('store.json');\n      const models = await store.get<any[]>('aiModelList');\n      if (!models) return;\n      \n      // 查找TTS模型配置，适配新的多模型数据结构\n      let currentSpeed = 1;\n      for (const config of models) {\n        // 检查新的 models 数组结构\n        if (config.models && config.models.length > 0) {\n          const targetModel = config.models.find((model: any) => \n            model.id === audioModel && model.modelType === 'tts'\n          );\n          if (targetModel && targetModel.speed !== undefined) {\n            currentSpeed = targetModel.speed;\n            break;\n          }\n        } else {\n          // 向后兼容：处理旧的单模型结构\n          if (config.key === audioModel && config.modelType === 'tts' && config.speed !== undefined) {\n            currentSpeed = config.speed;\n            break;\n          }\n        }\n      }\n      \n      setSpeed(currentSpeed);\n      setAiModelList(models);\n    }\n    loadSpeed();\n  }, [audioModel]);\n\n  // 保存TTS语速设置\n  const handleSpeedChange = async (value: number[]) => {\n    const newSpeed = value[0];\n    setSpeed(newSpeed);\n    \n    if (!audioModel) return;\n    \n    const store = await Store.load('store.json');\n    const models = await store.get<any[]>('aiModelList') || [];\n    \n    // 更新TTS模型的语速设置，适配新的多模型数据结构\n    const updatedModels = models.map(config => {\n      // 检查新的 models 数组结构\n      if (config.models && config.models.length > 0) {\n        const updatedConfig = { ...config };\n        updatedConfig.models = config.models.map((model: any) => {\n          if (model.id === audioModel && model.modelType === 'tts') {\n            return { ...model, speed: newSpeed };\n          }\n          return model;\n        });\n        return updatedConfig;\n      } else {\n        // 向后兼容：处理旧的单模型结构\n        if (config.key === audioModel && config.modelType === 'tts') {\n          return { ...config, speed: newSpeed };\n        }\n        return config;\n      }\n    });\n    \n    setAiModelList(updatedModels);\n    await store.set('aiModelList', updatedModels);\n    await store.save();\n  };\n\n  return (\n    <ItemGroup className=\"gap-6\">\n      {/* TTS朗读设置部分 */}\n      <div className=\"space-y-2\">\n        <h3 className=\"text-sm font-medium text-foreground\">{t('tts.title')}</h3>\n        <p className=\"text-xs text-muted-foreground\">{t('tts.desc')}</p>\n      </div>\n\n      <ItemGroup className=\"gap-4\">\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><Volume2 className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('mode.title')}</ItemTitle>\n            <ItemDescription>{t('tts.modeDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select value={textToSpeechMode} onValueChange={(value) => setTextToSpeechMode(value as SpeechMode)}>\n              <SelectTrigger className=\"w-[180px]\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {modeOptions.map((option) => (\n                  <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><Volume2 className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('tts.model.title')}</ItemTitle>\n            <ItemDescription>{t('tts.model.desc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <ModelSelect modelKey=\"tts\" />\n          </ItemActions>\n        </Item>\n\n        {audioModel && (\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\"><Gauge className=\"size-4\" /></ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('tts.speed.title')}</ItemTitle>\n              <ItemDescription>{t('tts.speed.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div className=\"flex items-center gap-4\">\n                <Slider\n                  value={[speed]}\n                  onValueChange={handleSpeedChange}\n                  min={0.5}\n                  max={2}\n                  step={0.1}\n                  className=\"w-[180px]\"\n                />\n                <span className=\"text-zinc-500 w-10\">{speed}x</span>\n              </div>\n            </ItemActions>\n          </Item>\n        )}\n      </ItemGroup>\n\n      {/* STT语音识别设置部分 */}\n      <div className=\"space-y-2 mt-8\">\n        <h3 className=\"text-sm font-medium text-foreground\">{t('stt.title')}</h3>\n        <p className=\"text-xs text-muted-foreground\">{t('stt.desc')}</p>\n      </div>\n\n      <ItemGroup className=\"gap-4\">\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><Mic className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('stt.model.title')}</ItemTitle>\n            <ItemDescription>{t('stt.model.desc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <ModelSelect modelKey=\"stt\" />\n          </ItemActions>\n        </Item>\n      </ItemGroup>\n    </ItemGroup>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/chat/condense-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { Switch } from '@/components/ui/switch'\nimport { Slider } from '@/components/ui/slider'\nimport { Shield, AlignLeft, MessageSquare } from 'lucide-react'\nimport useSettingStore from '@/stores/setting'\n\nexport function CondenseSettings() {\n  const t = useTranslations('settings.chat.condense')\n  const {\n    enableCondense,\n    setEnableCondense,\n    keepLatestCount,\n    setKeepLatestCount,\n    condenseMaxLength,\n    setCondenseMaxLength,\n  } = useSettingStore()\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold mb-4\">{t('title')}</h3>\n\n      {/* 启用摘要 */}\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\">\n          <MessageSquare className=\"size-4\" />\n        </ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('enable.title')}</ItemTitle>\n          <ItemDescription>{t('enable.desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <Switch\n            checked={enableCondense}\n            onCheckedChange={setEnableCondense}\n          />\n        </ItemActions>\n      </Item>\n\n      {enableCondense && (\n        <>\n          {/* 保留最新条数 */}\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <Shield className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('keepLatest.title')}</ItemTitle>\n              <ItemDescription>{t('keepLatest.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div className=\"space-y-3 w-[180px]\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-xs text-muted-foreground\">1</span>\n                  <span className=\"text-xs font-medium\">{keepLatestCount}</span>\n                  <span className=\"text-xs text-muted-foreground\">10</span>\n                </div>\n                <Slider\n                  value={[keepLatestCount]}\n                  onValueChange={(value) => setKeepLatestCount(value[0])}\n                  min={1}\n                  max={10}\n                  step={1}\n                  className=\"w-full\"\n                />\n              </div>\n            </ItemActions>\n          </Item>\n\n          {/* 摘要长度限制 */}\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <AlignLeft className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('maxLength.title')}</ItemTitle>\n              <ItemDescription>{t('maxLength.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <div className=\"space-y-3 w-[180px]\">\n                <div className=\"flex items-center justify-between\">\n                  <span className=\"text-xs text-muted-foreground\">50</span>\n                  <span className=\"text-xs font-medium\">{condenseMaxLength}</span>\n                  <span className=\"text-xs text-muted-foreground\">500</span>\n                </div>\n                <Slider\n                  value={[condenseMaxLength]}\n                  onValueChange={(value) => setCondenseMaxLength(value[0])}\n                  min={50}\n                  max={500}\n                  step={10}\n                  className=\"w-full\"\n                />\n              </div>\n            </ItemActions>\n          </Item>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/chat/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { SettingType } from '../components/setting-base'\nimport { MessageSquare } from 'lucide-react'\nimport { CondenseSettings } from './condense-settings'\nimport { DefaultModelsSettings } from '../components/default-models-settings'\nimport { ToolbarSettings } from './toolbar-settings'\n\nexport default function ChatSettingsPage() {\n  const t = useTranslations('settings.chat')\n\n  return (\n    <SettingType\n      id=\"chat\"\n      title={t('title')}\n      desc={t('desc')}\n      icon={<MessageSquare className=\"size-4 lg:size-6\" />}\n    >\n      <div className=\"space-y-4\">\n        <DefaultModelsSettings type=\"chat\" />\n        <ToolbarSettings />\n        <CondenseSettings />\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/chat/primary-model-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { BotMessageSquare } from 'lucide-react'\nimport { ModelSelect } from '../components/model-select'\n\nexport function PrimaryModelSettings() {\n  const t = useTranslations('settings.chat.primaryModel')\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\">\n        <BotMessageSquare className=\"size-4\" />\n      </ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('model.title')}</ItemTitle>\n        <ItemDescription>{t('model.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <ModelSelect modelKey=\"primaryModel\" />\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/chat/toolbar-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  BotMessageSquare,\n  Drama,\n  ServerCrash,\n  Database,\n  Clipboard,\n  GripVertical\n} from 'lucide-react'\nimport useSettingStore, { ChatToolbarItem } from '@/stores/setting'\nimport {\n  DndContext,\n  closestCenter,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  verticalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\n// 工具配置：图标和描述键\nconst TOOL_CONFIGS = {\n  modelSelect: {\n    icon: <BotMessageSquare className=\"size-4\" />,\n    titleKey: 'record.chat.input.modelSelect.tooltip',\n    descKey: 'settings.chat.toolbar.chatToolbar.modelSelect.desc',\n  },\n  promptSelect: {\n    icon: <Drama className=\"size-4\" />,\n    titleKey: 'record.chat.input.promptSelect.tooltip',\n    descKey: 'settings.chat.toolbar.chatToolbar.promptSelect.desc',\n  },\n  mcpButton: {\n    icon: <ServerCrash className=\"size-4\" />,\n    titleKey: 'mcp.selectServers',\n    descKey: 'settings.chat.toolbar.chatToolbar.mcpButton.desc',\n  },\n  ragSwitch: {\n    icon: <Database className=\"size-4\" />,\n    titleKey: 'settings.chat.toolbar.chatToolbar.ragSwitch.title',\n    descKey: 'settings.chat.toolbar.chatToolbar.ragSwitch.desc',\n  },\n  clipboardMonitor: {\n    icon: <Clipboard className=\"size-4\" />,\n    titleKey: 'settings.chat.toolbar.chatToolbar.clipboardMonitor.title',\n    descKey: 'settings.chat.toolbar.chatToolbar.clipboardMonitor.desc',\n  },\n}\n\n// 可排序的工具栏项组件\ninterface SortableItemProps {\n  item: ChatToolbarItem\n  config: typeof TOOL_CONFIGS[keyof typeof TOOL_CONFIGS]\n  onToggle: (id: string) => void\n  t: (key: string) => string\n}\n\nfunction SortableItem({ item, config, onToggle, t }: SortableItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: item.id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  return (\n    <div ref={setNodeRef} style={style}>\n      <div className=\"flex items-center gap-3 p-3 border rounded-lg bg-background hover:bg-accent/50 transition-colors\">\n        {/* 拖拽句柄 */}\n        <div {...attributes} {...listeners} className=\"cursor-grab active:cursor-grabbing shrink-0\">\n          <GripVertical className=\"size-4 text-muted-foreground\" />\n        </div>\n\n        {/* 工具图标 */}\n        <div className=\"shrink-0 text-muted-foreground\">\n          {config?.icon}\n        </div>\n\n        {/* 标题和描述 */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"text-sm font-medium\">{config ? t(config.titleKey) : item.id}</div>\n          <div className=\"text-xs text-muted-foreground truncate\">{config ? t(config.descKey) : ''}</div>\n        </div>\n\n        {/* 开关 */}\n        <div onClick={(e) => e.stopPropagation()} className=\"shrink-0\">\n          <Switch\n            checked={item.enabled}\n            onCheckedChange={() => onToggle(item.id)}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport function ToolbarSettings() {\n  const t = useTranslations()\n  const { chatToolbarConfigPc, setChatToolbarConfigPc, chatToolbarConfigMobile, setChatToolbarConfigMobile } = useSettingStore()\n\n  // 根据设备类型选择配置\n  const config = chatToolbarConfigMobile.length > 0 ? chatToolbarConfigMobile : chatToolbarConfigPc\n  const setConfig = chatToolbarConfigMobile.length > 0 ? setChatToolbarConfigMobile : setChatToolbarConfigPc\n\n  // 拖拽传感器配置\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    })\n  )\n\n  const handleToggle = async (id: string) => {\n    const newConfig = config.map(item =>\n      item.id === id ? { ...item, enabled: !item.enabled } : item\n    )\n    await setConfig(newConfig)\n  }\n\n  // 处理拖拽结束\n  const handleDragEnd = async (event: DragEndEvent) => {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const oldIndex = config.findIndex((item) => item.id === active.id)\n      const newIndex = config.findIndex((item) => item.id === over.id)\n      const newItems = arrayMove(config, oldIndex, newIndex)\n      const updatedItems = newItems.map((item, index) => ({\n        ...item,\n        order: index,\n      }))\n      await setConfig(updatedItems)\n    }\n  }\n\n  // 获取可排序的工具列表（排除 newChat 和不在 TOOL_CONFIGS 中的项）\n  const sortableItems = config\n    .filter(item => item.id !== 'newChat' && item.id in TOOL_CONFIGS)\n    .sort((a, b) => a.order - b.order)\n\n  return (\n    <div className=\"space-y-4\">\n      {/* 标题 */}\n      <h3 className=\"text-lg font-semibold\">{t('settings.chat.toolbar.title')}</h3>\n\n      {/* 工具列表 */}\n      <div className=\"space-y-1\">\n        <DndContext\n          sensors={sensors}\n          collisionDetection={closestCenter}\n          onDragEnd={handleDragEnd}\n        >\n          <SortableContext\n            items={sortableItems.map(item => item.id)}\n            strategy={verticalListSortingStrategy}\n          >\n            {sortableItems.map((item) => (\n              <SortableItem\n                key={item.id}\n                item={item}\n                config={TOOL_CONFIGS[item.id as keyof typeof TOOL_CONFIGS]}\n                onToggle={handleToggle}\n                t={t}\n              />\n            ))}\n          </SortableContext>\n        </DndContext>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/components/default-models-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { BotMessageSquare, PenTool, Zap, GitCommit, FileText, Lightbulb } from 'lucide-react'\nimport { ModelSelect } from './model-select'\n\ninterface DefaultModelsSettingsProps {\n  type: 'chat' | 'editor' | 'record'\n}\n\nexport function DefaultModelsSettings({ type }: DefaultModelsSettingsProps) {\n  const t = useTranslations('settings')\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold\">{t('defaultModels.title')}</h3>\n\n      {/* Chat - Primary Model */}\n      {type === 'chat' && (\n        <>\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <BotMessageSquare className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('chat.primaryModel.model.title')}</ItemTitle>\n              <ItemDescription>{t('chat.primaryModel.model.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey=\"primaryModel\" />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <FileText className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('chat.condense.model.title')}</ItemTitle>\n              <ItemDescription>{t('chat.condense.model.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey=\"condense\" />\n            </ItemActions>\n          </Item>\n\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <Lightbulb className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('chat.inspiration.model.title')}</ItemTitle>\n              <ItemDescription>{t('chat.inspiration.model.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey=\"inspiration\" />\n            </ItemActions>\n          </Item>\n        </>\n      )}\n\n      {/* Record - MarkDesc */}\n      {type === 'record' && (\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\">\n            <PenTool className=\"size-4\" />\n          </ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('record.model.markDesc.title')}</ItemTitle>\n            <ItemDescription>{t('record.model.markDesc.desc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <ModelSelect modelKey=\"markDesc\" />\n          </ItemActions>\n        </Item>\n      )}\n\n      {/* Editor - Completion & Commit */}\n      {type === 'editor' && (\n        <>\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <GitCommit className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('editor.commit.model.title')}</ItemTitle>\n              <ItemDescription>{t('editor.commit.model.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey=\"commit\" />\n            </ItemActions>\n          </Item>\n          <Item variant=\"outline\">\n            <ItemMedia variant=\"icon\">\n              <Zap className=\"size-4\" />\n            </ItemMedia>\n            <ItemContent>\n              <ItemTitle>{t('editor.completion.model.title')}</ItemTitle>\n              <ItemDescription>{t('editor.completion.model.desc')}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey=\"completion\" />\n            </ItemActions>\n          </Item>\n        </>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/components/model-select.tsx",
    "content": "import * as React from \"react\"\nimport { useEffect, useState } from \"react\"\nimport { AiConfig, ModelConfig } from \"../../setting/config\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport useSettingStore from \"@/stores/setting\"\nimport { ChevronsUpDown, X } from \"lucide-react\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\"\nimport {\n  Check,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useTranslations } from \"next-intl\"\nimport { Button } from \"@/components/ui/button\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\n\ninterface GroupedModel {\n  configKey: string\n  configTitle: string\n  model: ModelConfig\n}\n\nexport function ModelSelect({modelKey}: {modelKey: string}) {\n  const [groupedModels, setGroupedModels] = useState<GroupedModel[]>([])\n  const { setCompletionModel, setMarkDescModel, setPrimaryModel, setImageMethodModel, setAudioModel, setSttModel, setEmbeddingModel, setRerankingModel, setCondenseModel, setInspirationModel } = useSettingStore()\n  const [model, setModel] = useState<string>('')\n  const [open, setOpen] = React.useState(false)\n  const t = useTranslations('settings.defaultModel')\n\n  // 获取正确的存储键名\n  function getStoreKey(modelKey: string): string {\n    switch (modelKey) {\n      case 'primaryModel':\n        return 'primaryModel'\n      case 'imageMethod':\n        return 'imageMethodModel'\n      case 'completion':\n        return 'completionModel'\n      case 'markDesc':\n        return 'markDescModel'\n      case 'audio':\n      case 'tts':\n        return 'audioModel'\n      case 'stt':\n        return 'sttModel'\n      case 'embedding':\n        return 'embeddingModel'\n      case 'reranking':\n        return 'rerankingModel'\n      case 'condense':\n        return 'condenseModel'\n      case 'inspiration':\n        return 'inspirationModel'\n      default:\n        return `${modelKey}Model`\n    }\n  }\n\n  function setPrimaryModelHandler(primaryModel: string) {\n    setModel(primaryModel)\n    switch (modelKey) {\n      case 'primaryModel':\n        setPrimaryModel(primaryModel)\n        break;\n      case 'imageMethod':\n        setImageMethodModel(primaryModel)\n        break;\n      case 'completion':\n        setCompletionModel(primaryModel)\n        break;\n      case 'markDesc':\n        setMarkDescModel(primaryModel)\n        break;\n      case 'audio':\n      case 'tts':\n        setAudioModel(primaryModel)\n        break;\n      case 'stt':\n        setSttModel(primaryModel)\n        break;\n      case 'embedding':\n        setEmbeddingModel(primaryModel)\n        break;\n      case 'reranking':\n        setRerankingModel(primaryModel)\n        break;\n      case 'condense':\n        setCondenseModel(primaryModel)\n        break;\n      case 'inspiration':\n        setInspirationModel(primaryModel)\n        break;\n      default:\n        break;\n    }\n  }\n\n  // 获取需要过滤的模型类型\n  function getTargetModelType(modelKey: string): string {\n    switch (modelKey) {\n      case 'embedding':\n        return 'embedding'\n      case 'reranking':\n        return 'rerank'\n      case 'audio':\n      case 'tts':\n        return 'tts'\n      case 'stt':\n        return 'stt'\n      default:\n        return 'chat'\n    }\n  }\n\n  async function initModelList() {\n    const store = await Store.load('store.json');\n    const aiConfigs = await store.get<AiConfig[]>('aiModelList')\n    if (!aiConfigs) return\n    const models: GroupedModel[] = []\n    const targetModelType = getTargetModelType(modelKey)\n    \n    aiConfigs.forEach(config => {\n      // 检查配置是否有效\n      if (!config.baseURL) return\n      \n      // 处理新的 models 数组结构\n      if (config.models && config.models.length > 0) {\n        config.models.forEach(model => {\n          // 根据modelKey过滤对应类型的模型\n          if (model.modelType === targetModelType && model.model) {\n            models.push({\n              configKey: config.key,\n              configTitle: config.title,\n              model,\n            })\n          }\n        })\n      } else {\n        // 向后兼容：处理旧的单模型结构\n        const configModelType = config.modelType || 'chat'\n        if (configModelType === targetModelType && config.model) {\n          models.push({\n            configKey: config.key,\n            configTitle: config.title,\n            model: {\n              id: config.key,\n              model: config.model,\n              modelType: configModelType,\n              temperature: config.temperature,\n              topP: config.topP,\n              voice: config.voice,\n              enableStream: config.enableStream\n            }\n          })\n        }\n      }\n    })\n\n    setGroupedModels(models)\n    \n    const storeKey = getStoreKey(modelKey)\n    const primaryModel = await store.get<string>(storeKey)\n    if (!primaryModel) return\n    setPrimaryModelHandler(primaryModel)\n  }\n\n  async function modelSelectChangeHandler(e: string) {\n    setPrimaryModelHandler(e)\n    const store = await Store.load('store.json');\n    const storeKey = getStoreKey(modelKey)\n    store.set(storeKey, e)\n    await store.save()\n  }\n\n  async function resetDefaultModel() {\n    const store = await Store.load('store.json');\n    const storeKey = getStoreKey(modelKey)\n    store.set(storeKey, '')\n    await store.save()\n    setPrimaryModelHandler('')\n  }\n\n  // 检查模型是否被选中（支持向后兼容）\n  const isModelSelected = (modelId: string): boolean => {\n    if (!model) return false\n    \n    // 首先尝试精确匹配（新格式的组合键）\n    if (model === modelId) return true\n    \n    // 向后兼容匹配（旧格式的单独ID）\n    if (modelId.includes('-')) {\n      const parts = modelId.split('-')\n      const originalId = parts.slice(2).join('-') // 去掉 config.key 部分\n      return originalId === model\n    }\n    \n    return false\n  }\n\n  // 查找当前选中的模型显示信息\n  const findSelectedModelDisplay = () => {\n    if (!model || !groupedModels.length) return null\n    \n    // 首先尝试精确匹配（新格式的组合键）\n    let selectedItem = groupedModels.find(item => item.model.id === model)\n    \n    // 如果没找到，尝试向后兼容匹配（旧格式的单独ID）\n    if (!selectedItem) {\n      selectedItem = groupedModels.find(item => {\n        // 对于新格式的组合键，提取原始ID进行匹配\n        if (item.model.id.includes('-')) {\n          const parts = item.model.id.split('-')\n          const originalId = parts.slice(2).join('-') // 去掉 config.key 部分\n          return originalId === model\n        }\n        return item.model.id === model\n      })\n    }\n    \n    if (selectedItem) {\n      return `${selectedItem.model.model}(${selectedItem.configTitle})`\n    }\n    \n    return null\n  }\n\n  // 按配置分组模型\n  const groupedByConfig = groupedModels.reduce((acc, item) => {\n    if (!acc[item.configTitle]) {\n      acc[item.configTitle] = []\n    }\n    acc[item.configTitle].push(item)\n    return acc\n  }, {} as Record<string, GroupedModel[]>)\n\n  useEffect(() => {\n    initModelList()\n  }, [])\n  \n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <div className=\"flex gap-2\">\n        <PopoverTrigger asChild>\n          <div className=\"flex-1 overflow-hidden\">\n            <Button\n              variant=\"outline\"\n              role=\"combobox\"\n              aria-expanded={open}\n              className=\"w-[280px] justify-between\"\n            >\n              {model\n                ? findSelectedModelDisplay()\n                : modelKey === 'primaryModel' ? t('noModel') : t('tooltip')}\n              <ChevronsUpDown className=\"opacity-50\" />\n            </Button>\n          </div>\n        </PopoverTrigger>\n        <TooltipButton\n          disabled={!model}\n          icon={<X className=\"h-4 w-4\" />}\n          onClick={resetDefaultModel}\n          variant=\"default\"\n          tooltipText={t('tooltip')}\n        />\n      </div>\n      <PopoverContent align=\"end\" className=\"p-0\">\n        <Command>\n          <CommandInput placeholder={t('placeholder')} className=\"h-9\" />\n          <CommandList>\n            <CommandEmpty>No model found.</CommandEmpty>\n            {Object.entries(groupedByConfig).map(([configTitle, models]) => (\n              <CommandGroup key={configTitle} heading={configTitle}>\n                {models.map((item) => (\n                  <CommandItem\n                    key={item.model.id}\n                    value={item.model.id}\n                    onSelect={(currentValue) => {\n                      modelSelectChangeHandler(currentValue)\n                      setOpen(false)\n                    }}\n                  >\n                    {item.model.model}\n                    <Check\n                      className={cn(\n                        \"ml-auto\",\n                        isModelSelected(item.model.id) ? \"opacity-100\" : \"opacity-0\"\n                      )}\n                    />\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            ))}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/components/setting-base.tsx",
    "content": "export function SettingType(\n  {id, title, icon, desc, children}:\n  { id: string, title: string, icon?: React.ReactNode, desc?: string, children?: React.ReactNode}\n) {\n  return <div id={id} className=\"flex flex-col space-y-4\">\n    <div className=\"mb-4\">\n      <h2 className=\"text-xl w-full font-bold flex items-center gap-2 mb-2\">\n        {icon}\n        {title}\n      </h2>\n      {desc && <p className=\"text-sm text-muted-foreground\">{desc}</p>}\n    </div>\n    {children}\n  </div>\n}\n\nexport function FormItem({title, desc, children}: { title: string, desc?: string, children: React.ReactNode}) {\n  return <div className=\"flex flex-col w-full\">\n    <div className=\"text-sm mb-2 font-bold\">{title}</div>\n    {children}\n    {desc && <p className=\"text-sm text-muted-foreground mt-2\">{desc}</p>}\n  </div>\n}"
  },
  {
    "path": "src/app/core/setting/components/setting-tab.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\"\nimport { usePathname, useRouter } from \"next/navigation\";\nimport baseConfig from '../config'\nimport { useTranslations } from 'next-intl'\nimport useSettingStore from \"@/stores/setting\"\nimport { Separator } from \"@/components/ui/separator\";\n\nexport function SettingTab() {\n  const [currentPage, setCurrentPage] = useState('about')\n  const router = useRouter()\n  const pathname = usePathname()\n  const t = useTranslations('settings')\n  const { setLastSettingPage } = useSettingStore()\n  \n  // Add translations to the config\n  const config = baseConfig.map(item => {\n    if (typeof item === 'string') return item\n    return {\n      ...item,\n      title: t(`${item.anchor}.title`)\n    }\n  })\n\n  function handleNavigation(anchor: string) {\n    setCurrentPage(anchor)\n    router.push(`/core/setting/${anchor}`)\n    // 记录最后访问的设置页面\n    setLastSettingPage(anchor)\n  }\n\n  useEffect(() => {\n    // 从当前URL路径中提取当前页面\n    const pageName = pathname.split('/').pop()\n    if (pageName && pageName !== 'setting') {\n      setCurrentPage(pageName)\n      // 记录最后访问的设置页面\n      setLastSettingPage(pageName)\n    }\n  }, [pathname, setLastSettingPage])\n\n  return (\n    <div className=\"flex flex-col w-56 justify-between h-full bg-sidebar border-r\">\n      <ul className=\"w-full p-4 flex flex-col justify-between flex-1 overflow-y-auto\">\n        {\n          config.map((item, index) => {\n            if (typeof item === 'string') return (\n              <Separator key={index} className=\"my-2\" />\n            )\n            return (\n              <li\n                key={item.anchor}\n                className={`\n                  w-full px-4 py-2.5 rounded-md cursor-pointer flex items-center gap-3 text-sm transition-colors\n                  ${currentPage === item.anchor\n                    ? 'bg-primary text-primary-foreground'\n                    : 'hover:bg-accent hover:text-accent-foreground text-foreground/80'\n                  }\n                `}\n                onClick={() => handleNavigation(item.anchor)}\n              >\n                <span className=\"size-4 shrink-0 flex items-center justify-center\">\n                  {item.icon}\n                </span>\n                <span className=\"truncate\">{item.title}</span>\n              </li>\n            )\n          })\n        }\n      </ul>\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/components/upload-store.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { DownloadCloud, Loader2, UploadCloud } from \"lucide-react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { uint8ArrayToBase64, uploadFile as uploadGithubFile, getFiles as githubGetFiles, decodeBase64ToString } from \"@/lib/sync/github\";\nimport { getFiles as giteeGetFiles, uploadFile as uploadGiteeFile } from \"@/lib/sync/gitee\";\nimport { uploadFile as uploadGitlabFile, getFiles as gitlabGetFiles, getFileContent as gitlabGetFileContent } from \"@/lib/sync/gitlab\";\nimport { uploadFile as uploadGiteaFile, getFiles as giteaGetFiles, getFileContent as giteaGetFileContent } from \"@/lib/sync/gitea\";\nimport { getSyncRepoName } from \"@/lib/sync/repo-utils\";\nimport { getRemoteFileContent } from \"@/lib/sync/remote-file\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { useState } from \"react\";\nimport { isMobileDevice } from \"@/lib/check\";\nimport { relaunch } from \"@tauri-apps/plugin-process\";\nimport { confirm } from \"@tauri-apps/plugin-dialog\";\nimport { useTranslations } from \"next-intl\";\nimport useUsername from \"@/hooks/use-username\";\nimport { filterSyncData, mergeSyncData } from \"@/config/sync-exclusions\";\n\nexport default function UploadStore() {\n  const [upLoading, setUploading] = useState(false)\n  const [downLoading, setDownLoading] = useState(false)\n  const t = useTranslations('settings.uploadStore')\n  const username = useUsername()\n\n  async function upload() {\n    const confirmRef = await confirm(t('uploadConfirm'))\n    if (!confirmRef) return\n    setUploading(true)\n    const path = '.settings'\n    const filename = 'store.json'\n    \n    // 读取并过滤配置\n    const store = await Store.load('store.json');\n    const allSettings: Record<string, any> = {}\n    const entries = await store.entries()\n    for (const [key, value] of entries) {\n      allSettings[key] = value\n    }\n    \n    // 过滤掉不应同步的字段（如工作区路径等）\n    const syncableSettings = filterSyncData(allSettings)\n    const filteredContent = JSON.stringify(syncableSettings, null, 2)\n    const file = new TextEncoder().encode(filteredContent)\n    \n    const primaryBackupMethod = await store.get('primaryBackupMethod')\n    let files: any;\n    let res;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: `${path}/${filename}`, repo: githubRepo })\n        res = await uploadGithubFile({\n          file: uint8ArrayToBase64(file),\n          repo: githubRepo,\n          path,\n          filename,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: `${path}/${filename}`, repo: giteeRepo })\n        res = await uploadGiteeFile({\n          file: uint8ArrayToBase64(file),\n          repo: giteeRepo,\n          path,\n          filename,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitlab':\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        files = await gitlabGetFiles({ path, repo: gitlabRepo })\n        const storeFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGitlabFile({\n          file: uint8ArrayToBase64(file),\n          repo: gitlabRepo,\n          path,\n          filename,\n          sha: storeFile?.sha || '',\n        })\n        break;\n      case 'gitea':\n        const giteaRepo = await getSyncRepoName('gitea')\n        files = await giteaGetFiles({ path, repo: giteaRepo })\n        const giteaStoreFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGiteaFile({\n          file: uint8ArrayToBase64(file),\n          repo: giteaRepo,\n          path,\n          filename,\n          sha: giteaStoreFile?.sha || '',\n        })\n        break;\n    }\n    if (res) {\n      toast({\n        description: t('uploadSuccess'),\n      })\n    }\n    setUploading(false)\n  }\n\n  async function download() {\n    const res = await confirm(t('downloadConfirm'))\n    if (!res) return\n    setDownLoading(true)\n    const path = '.settings'\n    const filename = 'store.json'\n    const store = await Store.load('store.json');\n    \n    // 获取本地配置（用于保留排除字段）\n    const localSettings: Record<string, any> = {}\n    const entries = await store.entries()\n    for (const [key, value] of entries) {\n      localSettings[key] = value\n    }\n    \n    const primaryBackupMethod = await store.get('primaryBackupMethod')\n    let file;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo2 = await getSyncRepoName('github')\n        file = await githubGetFiles({ path: `${path}/${filename}`, repo: githubRepo2 })\n        break;\n      case 'gitee':\n        const giteeRepo2 = await getSyncRepoName('gitee')\n        file = await giteeGetFiles({ path: `${path}/${filename}`, repo: giteeRepo2 })\n        break;\n      case 'gitlab':\n        const gitlabRepo2 = await getSyncRepoName('gitlab')\n        file = await gitlabGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: gitlabRepo2 })\n        break;\n      case 'gitea':\n        const giteaRepo2 = await getSyncRepoName('gitea')\n        file = await giteaGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: giteaRepo2 })\n        break;\n    }\n    if (file) {\n      const configJson = decodeBase64ToString(getRemoteFileContent(file, `${path}/${filename}`))\n      const remoteSettings = JSON.parse(configJson)\n      \n      // 合并配置：使用远程配置，但保留本地的排除字段（如工作区路径等）\n      const mergedSettings = mergeSyncData(localSettings, remoteSettings)\n      \n      // 保存合并后的配置\n      const keys = Object.keys(mergedSettings)\n      await Promise.allSettled(keys.map(async key => await store.set(key, mergedSettings[key])))\n      await store.save()\n      \n      if (isMobileDevice()) {\n        toast({\n          description: t('downloadSuccess'),\n        })\n      } else {\n        relaunch()\n      }\n    }\n    setDownLoading(false)\n  }\n\n  return (\n    username ? (\n    <div className=\"flex gap-1 flex-col md:border-t justify-center items-center\">\n      <div className=\"flex gap-2\">\n        <Button variant={'ghost'} size={'sm'} onClick={upload} disabled={upLoading}>\n          {upLoading ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : <UploadCloud />}\n          <span className=\"hidden md:inline\">{t('upload')}</span>\n        </Button>\n        <Button variant={'ghost'} size={'sm'} onClick={download} disabled={downLoading}>\n          {downLoading ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : <DownloadCloud />}\n          <span className=\"hidden md:inline\">{t('download')}</span>\n        </Button>\n      </div>\n    </div>\n    ) : null\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/config.tsx",
    "content": "import {\n  BotMessageSquare,\n  LayoutTemplate,\n  ScanText,\n  Store,\n  UserRoundCog,\n  Drama,\n  FolderOpen,\n  DatabaseBackup,\n  ImageUp,\n  FileCog,\n  Book,\n  KeyboardIcon,\n  Volume2,\n  Settings,\n  Puzzle,\n  Sparkles,\n  MessageSquare,\n  PenTool,\n  Brain,\n} from \"lucide-react\"\n\nconst baseConfig = [\n  {\n    icon: <Store className=\"size-4 md:size-6\" />,\n    anchor: 'about',\n  },\n  {\n    icon: <Settings className=\"size-4 md:size-6\" />,\n    anchor: 'general',\n  },\n  {\n    icon: <MessageSquare className=\"size-4 md:size-6\" />,\n    anchor: 'chat',\n  },\n  {\n    icon: <FileCog className=\"size-4 md:size-6\" />,\n    anchor: 'editor',\n  },\n  {\n    icon: <PenTool className=\"size-4 md:size-6\" />,\n    anchor: 'record',\n  },\n  '-',\n  {\n    icon: <DatabaseBackup className=\"size-4 md:size-6\" />,\n    anchor: 'sync',\n  },\n  {\n    icon: <ImageUp className=\"size-4 md:size-6\" />,\n    anchor: 'imageHosting',\n  },\n  '-',\n  {\n    icon: <BotMessageSquare className=\"size-4 md:size-6\" />,\n    anchor: 'ai',\n  },\n  {\n    icon: <Book className=\"size-4 md:size-6\" />,\n    anchor: 'rag',\n  },\n  {\n    icon: <Puzzle className=\"size-4 md:size-6\" />,\n    anchor: 'mcp',\n  },\n  {\n    icon: <Sparkles className=\"size-4 md:size-6\" />,\n    anchor: 'skills',\n  },\n  {\n    icon: <Drama className=\"size-4 md:size-6\" />,\n    anchor: 'prompt',\n  },\n  {\n    icon: <Brain className=\"size-4 md:size-6\" />,\n    anchor: 'memories',\n  },\n  {\n    icon: <LayoutTemplate className=\"size-4 md:size-6\" />,\n    anchor: 'template',\n  },\n  '-',\n  {\n    icon: <FolderOpen className=\"size-4 md:size-6\" />,\n    anchor: 'file',\n  },\n  {\n    icon: <KeyboardIcon className=\"size-4 md:size-6\" />,\n    anchor: 'shortcuts',\n  },\n  {\n    icon: <ScanText className=\"size-4 md:size-6\" />,\n    anchor: 'imageMethod',\n  },\n  {\n    icon: <Volume2 className=\"size-4 md:size-6\" />,\n    anchor: 'audio',\n  },\n  '-',\n  {\n    icon: <UserRoundCog className=\"size-4 md:size-6\" />,\n    anchor: 'dev',\n  }\n]\n\nexport default baseConfig\n\nexport type ModelType = 'chat' | 'image' | 'video' | 'tts' | 'stt' | 'embedding' | 'rerank';\n\nexport interface ModelConfig {\n  id: string\n  model: string\n  modelType: ModelType\n  temperature?: number\n  topP?: number\n  voice?: string\n  enableStream?: boolean\n}\n\nexport interface AiConfig {\n  key: string\n  title: string\n  apiKey?: string\n  baseURL?: string\n  icon?: string\n  apiKeyUrl?: string\n  customHeaders?: Record<string, string>\n  models?: ModelConfig[]\n  // 保持向后兼容\n  model?: string\n  temperature?: number\n  topP?: number\n  modelType?: ModelType\n  voice?: string\n  speed?: number\n  enableStream?: boolean\n}\n\nexport interface Model {\n  id: string\n  object: string\n  created: number\n  owned_by: string\n}\n\n// Define base AI configuration without translations\nconst baseAiConfig: AiConfig[] = [\n  {\n    key: 'siliconflow',\n    title: 'SiliconFlow',\n    baseURL: 'https://api.siliconflow.cn/v1',\n    icon: 'https://s2.loli.net/2025/09/09/D8Al2raSvewN5xn.jpg',\n    apiKeyUrl: 'https://cloud.siliconflow.cn/i/O2ciJeZw'\n  },\n  {\n    key: 'chatgpt',\n    title: 'ChatGPT',\n    baseURL: 'https://api.openai.com/v1',\n    icon: 'https://s2.loli.net/2025/06/25/cVMf586WTBYAju4.png',\n    apiKeyUrl: 'https://platform.openai.com/api-keys'\n  },\n  {\n    key: 'gemini',\n    title: 'Gemini',\n    baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai',\n    icon: 'https://s2.loli.net/2025/06/25/JU2jVxLFsW4lB6S.png',\n    apiKeyUrl: 'https://aistudio.google.com/app/apikey'\n  },\n  {\n    key: 'grok',\n    title: 'Grok',\n    baseURL: 'https://api.x.ai/v1',\n    icon: 'https://s2.loli.net/2025/06/25/JBZMluaobKq43QE.png',\n    apiKeyUrl: 'https://console.x.ai/'\n  },\n  {\n    key: 'ollama',\n    title: 'Ollama',\n    baseURL: 'http://localhost:11434/v1',\n    icon: 'https://s2.loli.net/2025/06/25/legkEpHACDBQ5Xz.png',\n  },\n  {\n    key: 'lmstudio',\n    title: 'LM Studio',\n    baseURL: 'http://localhost:1234/v1',\n    icon: 'https://s2.loli.net/2025/06/25/IifFV4HTQ9dpGZE.png',\n  },\n  {\n    key: 'deepseek',\n    title: 'DeepSeek',\n    baseURL: 'https://api.deepseek.com',\n    icon: 'https://s2.loli.net/2025/06/25/n39WmsCDbVLQzjr.png',\n    apiKeyUrl: 'https://platform.deepseek.com/api_keys'\n  },\n  {\n    key: 'openrouter',\n    title: 'OpenRouter',\n    baseURL: 'https://openrouter.ai/api/v1',\n    icon: 'https://s2.loli.net/2025/06/25/CTjSDHLl4XdvxM5.png',\n    apiKeyUrl: 'https://openrouter.ai/api-keys'\n  },\n  {\n    key: 'qiniu',\n    title: '七牛云',\n    baseURL: 'https://openai.qiniu.com/v1',\n    icon: 'https://s2.loli.net/2025/09/15/ALjNPveWrtmsfOY.png',\n    apiKeyUrl: 'https://s.qiniu.com/Znm6je'\n  },\n  {\n    key: '302',\n    title: '302.AI',\n    baseURL: 'https://api.302.ai/v1',\n    icon: 'https://s2.loli.net/2025/06/26/4CJOQ2U9ibvoGpR.png',\n    apiKeyUrl: 'https://share.302.ai/jfFrIP'\n  },\n  {\n    key: 'shengsuanyun',\n    title: '胜算云',\n    baseURL: 'https://router.shengsuanyun.com/api/v1',\n    icon: 'https://s2.loli.net/2025/09/15/4qjswKyaRfZ8OxW.png',\n    apiKeyUrl: 'https://www.shengsuanyun.com/?from=CH_KAFLGC9O'\n  },\n  {\n    key: 'gitee',\n    title: 'Gitee AI',\n    baseURL: 'https://ai.gitee.com/v1',\n    icon: 'https://s2.loli.net/2025/09/15/ih7aTnGPvELFsVc.png',\n    apiKeyUrl: 'https://ai.gitee.com/'\n  },\n]\n\nexport { baseAiConfig }"
  },
  {
    "path": "src/app/core/setting/defaultModel/page.tsx",
    "content": "'use client';\nimport { SettingType } from \"../components/setting-base\";\nimport { Setting } from \"./setting\";\nimport { Package } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\";\n\nexport default function DefaultModelPage() {\n  const t = useTranslations('settings.defaultModel');\n\n  return <SettingType id=\"defaultModel\" icon={<Package />} title={t('title')} desc={t('desc')}>\n    <Setting />\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/defaultModel/setting.tsx",
    "content": "import { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { ModelSelect } from \"../components/model-select\";\nimport { Highlighter } from \"lucide-react\";\n\nexport function Setting() {\n  const t = useTranslations('settings.defaultModel');\n\n  const options = [\n    {\n      title: t('options.markDesc.title'),\n      desc: t('options.markDesc.desc'),\n      modelKey: 'markDesc',\n      icon: <Highlighter className=\"size-4\" />\n    },\n  ]\n\n  return (\n    <ItemGroup className=\"gap-4\">\n      {options.map((option) => (\n      <Item key={option.modelKey} variant=\"outline\">\n        <ItemMedia variant=\"icon\">{option.icon}</ItemMedia>\n        <ItemContent>\n          <ItemTitle>{option.title}</ItemTitle>\n          <ItemDescription>{option.desc}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <ModelSelect modelKey={option.modelKey} />\n        </ItemActions>\n      </Item>\n      ))}\n    </ItemGroup>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/dev/page.tsx",
    "content": "'use client';\n\nimport { UserRoundCog } from \"lucide-react\"\nimport { SettingDev } from \"./setting-dev\";\n\nexport default function DevPage() {\n  return <SettingDev id=\"dev\" icon={<UserRoundCog />} />\n}\n"
  },
  {
    "path": "src/app/core/setting/dev/set-config.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { FileJson } from \"lucide-react\";\nimport { open, save } from \"@tauri-apps/plugin-dialog\";\nimport { useToast } from \"@/hooks/use-toast\";\nimport { BaseDirectory, copyFile, readTextFile, writeTextFile } from \"@tauri-apps/plugin-fs\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { isMobileDevice } from \"@/lib/check\";\nimport { relaunch } from \"@tauri-apps/plugin-process\";\nimport { useTranslations } from 'next-intl';\n\nexport default function SetConfig() {\n    const t = useTranslations('settings.dev');\n    const { toast } = useToast()\n    async function handleImport() {\n      try {\n        const file = await open({\n          title: t('importConfigTitle'),\n        })\n        if (file) {\n          // 验证 JSON 格式\n          const content = await readTextFile(file)\n          JSON.parse(content)\n\n          // 直接将文件写入 store.json 位置\n          await writeTextFile('store.json', content, { baseDir: BaseDirectory.AppData })\n\n          // 关闭已加载的 store 实例（如果有）\n          const existingStore = await Store.get('store.json')\n          if (existingStore) {\n            await existingStore.close()\n          }\n\n          // 重新加载 store，会自动从磁盘读取新写入的文件\n          await Store.load('store.json')\n\n          if (isMobileDevice()) {\n            toast({\n              description: t('importConfigSuccessMobile'),\n            })\n          } else {\n            relaunch()\n          }\n        }\n      } catch (error) {\n        toast({\n          title: '导入失败',\n          description: error instanceof Error ? error.message : String(error),\n          variant: 'destructive'\n        })\n      }\n    }\n    async function handleExport() {\n      const file = await save({\n        title: t('exportConfigTitle'),\n        defaultPath: 'store.json',\n      })\n      if (file) {\n        await copyFile('store.json', file, { fromPathBaseDir: BaseDirectory.AppData })\n        toast({ title: t('exportConfigSuccess') })\n      }\n    }\n    return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><FileJson className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('configFileTitle')}</ItemTitle>\n        <ItemDescription>{t('configFileDesc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Button variant=\"outline\" onClick={handleImport}>{t('importButton')}</Button>\n        <Button variant=\"outline\" onClick={handleExport}>{t('exportButton')}</Button>\n      </ItemActions>\n    </Item>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/dev/setting-dev.tsx",
    "content": "import { SettingType } from \"../components/setting-base\";\nimport { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { Button } from \"@/components/ui/button\";\nimport { useTranslations } from 'next-intl';\nimport { useToast } from \"@/hooks/use-toast\";\nimport { BaseDirectory, exists, remove } from \"@tauri-apps/plugin-fs\";\nimport { confirm, message } from '@tauri-apps/plugin-dialog';\nimport { getCurrentWindow } from '@tauri-apps/api/window';\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { Input } from \"@/components/ui/input\";\nimport { useEffect, useState } from \"react\";\nimport SetConfig from \"./set-config\";\nimport { Network, Database, FolderX } from \"lucide-react\";\n\nexport function SettingDev({id, icon}: {id: string, icon?: React.ReactNode}) {\n  const t = useTranslations();\n  const [proxy, setProxy] = useState('');\n  const { toast } = useToast()\n\n  async function handleClearData() {\n    const res = await confirm(t('settings.dev.clearDataConfirm'), {\n      title: t('settings.dev.clearData'),\n      kind: 'warning',\n    })\n    if (res) {\n      const store = await Store.load('store.json');\n      await store.clear()\n      await remove('store.json', { baseDir: BaseDirectory.AppData })\n      await remove('note.db', { baseDir: BaseDirectory.AppData })\n      message('数据已清理，请重启应用', {\n        title: '重启应用',\n        kind: 'info',\n      }).then(async () => {\n        await getCurrentWindow().close();\n      })\n    }\n  }\n\n  async function handleClearFile() {\n    const res = await confirm('确定清理文件吗？清理后将无法恢复！', {\n      title: '清理文件',\n      kind: 'warning',\n    })\n    if (res) {\n      const folders = ['screenshot', 'article', 'clipboard', 'image']\n      for (const folder of folders) {\n        const isFolderExists = await exists(folder, { baseDir: BaseDirectory.AppData})\n        if (isFolderExists) {\n          await remove(folder, { baseDir: BaseDirectory.AppData, recursive: true })\n        }\n      }\n      toast({ title: '文件已清理' })\n    }\n  }\n\n  async function proxyChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    setProxy(e.target.value)\n    const store = await Store.load('store.json');\n    await store.set('proxy', e.target.value)\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const proxy = await store.get<string>('proxy')\n      if (proxy) {\n        setProxy(proxy)\n      }\n    }\n    init()\n  }, [])\n\n  return (\n    <SettingType id={id} icon={icon} title={t('settings.dev.title')} desc={t('settings.dev.desc')}>\n      <ItemGroup className=\"gap-4\">\n        <Item variant=\"outline\" className=\"max-md:flex-col max-md:items-start\">\n          <ItemMedia variant=\"icon\"><Network className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.dev.proxyTitle')}</ItemTitle>\n            <ItemDescription>{t('settings.dev.proxy')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Input className=\"w-[300px]\" placeholder={t('settings.dev.proxyPlaceholder')} value={proxy} onChange={proxyChangeHandler} />\n          </ItemActions>\n        </Item>\n        \n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><Database className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.dev.clearDataTitle')}</ItemTitle>\n            <ItemDescription>{t('settings.dev.clearDataDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Button variant={\"destructive\"} onClick={handleClearData}>{t('settings.dev.clearButton')}</Button>\n          </ItemActions>\n        </Item>\n        \n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><FolderX className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.dev.clearFileTitle')}</ItemTitle>\n            <ItemDescription>{t('settings.dev.clearFileDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Button variant={\"destructive\"} onClick={handleClearFile}>{t('settings.dev.clearButton')}</Button>\n          </ItemActions>\n        </Item>\n        <SetConfig />\n      </ItemGroup>\n    </SettingType>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/editor/centered-content.tsx",
    "content": "'use client';\nimport { Switch } from \"@/components/ui/switch\";\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { useEffect, useState } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\nexport default function CenteredContent() {\n  const t = useTranslations('settings.editor');\n  const [state, setState] = useState(false)\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const centeredContent = await store.get<boolean>('centeredContent') || false\n      setState(centeredContent)\n    }\n    init()\n  }, [])\n\n  async function setStateHandler(state: boolean) {\n    const store = await Store.load('store.json');\n    await store.set('centeredContent', state)\n    setState(state)\n  }\n\n  return <Item variant=\"outline\">\n    <ItemContent>\n      <ItemTitle>{t('centeredContent')}</ItemTitle>\n      <ItemDescription>{t('centeredContentDesc')}</ItemDescription>\n    </ItemContent>\n    <ItemActions>\n      <Switch checked={state} onCheckedChange={setStateHandler}/>\n    </ItemActions>\n  </Item>\n}\n"
  },
  {
    "path": "src/app/core/setting/editor/commit.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { GitCommit } from 'lucide-react'\nimport { ModelSelect } from '../components/model-select'\n\nexport default function Commit() {\n  const t = useTranslations('settings.editor.commit')\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\">\n        <GitCommit className=\"size-4\" />\n      </ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('model.title')}</ItemTitle>\n        <ItemDescription>{t('model.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <ModelSelect modelKey=\"commit\" />\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/editor/completion.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { Zap } from 'lucide-react'\nimport { ModelSelect } from '../components/model-select'\n\nexport default function Completion() {\n  const t = useTranslations('settings.editor.completion')\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\">\n        <Zap className=\"size-4\" />\n      </ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('model.title')}</ItemTitle>\n        <ItemDescription>{t('model.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <ModelSelect modelKey=\"completion\" />\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/editor/outline.tsx",
    "content": "'use client'\nimport { Item, ItemGroup, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { useEffect, useState } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { DEFAULT_OUTLINE_POSITION, normalizeOutlinePosition, type OutlinePosition } from '@/lib/outline-preferences'\nimport {\n  Tabs,\n  TabsList,\n  TabsTrigger,\n} from \"@/components/ui/tabs\"\n\n\nexport default function Outline() {\n  const t = useTranslations('settings.editor');\n  const [enableOutline, setEnableOutline] = useState(false)\n  const [outlinePosition, setOutlinePosition] = useState<OutlinePosition>(DEFAULT_OUTLINE_POSITION)\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const outlinePosition = normalizeOutlinePosition(await store.get('outlinePosition'))\n      const enableOutline = await store.get<boolean>('enableOutline') || false\n      setEnableOutline(enableOutline)\n      setOutlinePosition(outlinePosition)\n    }\n    init()\n  }, [])\n\n  async function setPositionHandler(state: OutlinePosition) {\n    const store = await Store.load('store.json');\n    await store.set('outlinePosition', state)\n    setOutlinePosition(state)\n  }\n\n  async function setEnableOutlineHandler(state: boolean) {\n    const store = await Store.load('store.json');\n    await store.set('enableOutline', state)\n    setEnableOutline(state)\n  }\n\n  return <ItemGroup className=\"gap-4\">\n    <Item variant=\"outline\">\n      <ItemContent>\n        <ItemTitle>{t('outlineEnable')}</ItemTitle>\n        <ItemDescription>{t('outlineEnableDesc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Switch\n          checked={enableOutline}\n          onCheckedChange={setEnableOutlineHandler}\n        />\n      </ItemActions>\n    </Item>\n    <Item variant=\"outline\">\n      <ItemContent>\n        <ItemTitle>{t('outlinePosition')}</ItemTitle>\n        <ItemDescription>{t('outlinePositionDesc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Tabs defaultValue={DEFAULT_OUTLINE_POSITION} value={outlinePosition} onValueChange={(value) => setPositionHandler(normalizeOutlinePosition(value))}>\n          <TabsList className=\"grid w-full grid-cols-2\">\n            <TabsTrigger value=\"left\">{t('outlinePositionOptions.left')}</TabsTrigger>\n            <TabsTrigger value=\"right\">{t('outlinePositionOptions.right')}</TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </ItemActions>\n    </Item>\n  </ItemGroup>\n}\n"
  },
  {
    "path": "src/app/core/setting/editor/page.tsx",
    "content": "'use client';\nimport { UserRoundCog } from \"lucide-react\"\nimport { SettingType } from \"../components/setting-base\";\nimport { useTranslations } from 'next-intl';\nimport ShowUndoRedo from './show-undo-redo';\nimport CenteredContent from './centered-content';\nimport Outline from './outline';\nimport { DefaultModelsSettings } from '../components/default-models-settings';\n\nexport default function EditorSettingPage() {\n  const t = useTranslations('settings.editor');\n  return <SettingType id=\"editorSetting\" icon={<UserRoundCog />} title={t('title')} desc={t('desc')}>\n    <div className=\"space-y-4\">\n      <DefaultModelsSettings type=\"editor\" />\n      <h3 className=\"text-lg font-semibold\">{t('interfaceSettings')}</h3>\n      <CenteredContent />\n      <Outline />\n      <ShowUndoRedo />\n    </div>\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/editor/show-undo-redo.tsx",
    "content": "'use client'\n\nimport { Switch } from \"@/components/ui/switch\"\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { useTranslations } from 'next-intl'\nimport useSettingStore from '@/stores/setting'\n\nexport default function ShowUndoRedo() {\n  const t = useTranslations('settings.editor')\n  const { showEditorUndoRedo, setShowEditorUndoRedo } = useSettingStore()\n\n  return <Item variant=\"outline\">\n    <ItemContent>\n      <ItemTitle>{t('showUndoRedo')}</ItemTitle>\n      <ItemDescription>{t('showUndoRedoDesc')}</ItemDescription>\n    </ItemContent>\n    <ItemActions>\n      <Switch\n        checked={showEditorUndoRedo}\n        onCheckedChange={setShowEditorUndoRedo}\n      />\n    </ItemActions>\n  </Item>\n}\n"
  },
  {
    "path": "src/app/core/setting/file/page.tsx",
    "content": "'use client'\nimport { SettingWorkspace } from \"./setting-workspace\"\nimport { SettingAssets } from \"./setting-assets\"\nimport { SettingType } from \"../components/setting-base\"\nimport { FolderOpen } from \"lucide-react\"\nimport { useTranslations } from 'next-intl'\n\nexport default function SettingFilePage() {\n  const t = useTranslations('settings.file')\n\n  return (\n    <SettingType\n      id=\"file\"\n      title={t('title')}\n      desc={t('desc')}\n      icon={<FolderOpen className=\"w-5 h-5\" />}\n    >\n      <div className=\"space-y-8\">\n        <SettingWorkspace />\n        <SettingAssets />\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/file/setting-assets.tsx",
    "content": "'use client'\nimport { Input } from \"@/components/ui/input\"\nimport { FormItem } from \"../components/setting-base\"\nimport { useTranslations } from 'next-intl'\nimport useSettingStore from \"@/stores/setting\"\n\nexport function SettingAssets() {\n  const t = useTranslations('settings.file.assets')\n  const { assetsPath, setAssetsPath } = useSettingStore()\n  return (\n    <FormItem title={t('title')} desc={t('desc')}>\n      <Input placeholder={t('select')} value={assetsPath} onChange={(e) => setAssetsPath(e.target.value)} />\n    </FormItem>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/file/setting-workspace.tsx",
    "content": "'use client'\n\nimport { Button } from \"@/components/ui/button\"\nimport { FormItem } from \"../components/setting-base\"\nimport useSettingStore from \"@/stores/setting\"\nimport { open as openDialog } from '@tauri-apps/plugin-dialog'\nimport { BaseDirectory, exists, mkdir } from \"@tauri-apps/plugin-fs\"\nimport { useTranslations } from 'next-intl'\nimport useArticleStore from \"@/stores/article\"\nimport { useSkillsStore } from \"@/stores/skills\"\nimport { X, FolderOpen, History, Trash2, ChevronDown } from \"lucide-react\"\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from \"@/components/ui/command\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\"\nimport { useState } from \"react\"\n\nexport function SettingWorkspace() {\n  const {\n    workspacePath,\n    setWorkspacePath,\n    workspaceHistory,\n    removeWorkspaceHistory,\n    clearWorkspaceHistory\n  } = useSettingStore()\n  const {clearCollapsibleList, loadFileTree, setActiveFilePath, setCurrentArticle} = useArticleStore()\n  const { refreshSkills } = useSkillsStore()\n  const t = useTranslations('settings.file')\n  const [open, setOpen] = useState(false)\n\n  // 选择工作区目录\n  async function handleSelectWorkspace() {\n    try {\n      const selected = await openDialog({\n        directory: true,\n        multiple: false,\n        title: t('workspace.select')\n      })\n      \n      if (selected) {\n        const path = selected as string\n        await switchWorkspace(path)\n      }\n    } catch (error) {\n      console.error('选择工作区失败:', error)\n    }\n  }\n\n  // 切换工作区（统一处理）\n  async function switchWorkspace(path: string) {\n    try {\n      await setWorkspacePath(path)\n      await clearCollapsibleList()\n      setActiveFilePath('')\n      setCurrentArticle('')\n      await loadFileTree()\n      await refreshSkills()\n    } catch (error) {\n      console.error('切换工作区失败:', error)\n    }\n  }\n\n\n  // 清空所有历史记录\n  async function handleClearHistory() {\n    await clearWorkspaceHistory()\n  }\n\n  // 重置为默认工作区\n  async function handleResetWorkspace() {\n    try {\n      // 确保默认目录存在\n      const exists1 = await exists('article', { baseDir: BaseDirectory.AppData })\n      if (!exists1) {\n        await mkdir('article', { baseDir: BaseDirectory.AppData })\n      }\n      await setWorkspacePath('')\n      await clearCollapsibleList()\n      setActiveFilePath('')\n      setCurrentArticle('')\n      await loadFileTree()\n      await refreshSkills()\n    } catch (error) {\n      console.error('重置工作区失败:', error)\n    }\n  }\n\n  return (\n    <FormItem \n        title={t('workspace.current')} \n        desc={t('workspace.desc')}\n      >\n        <div className=\"space-y-3\">\n          {/* 当前工作区路径显示和选择 */}\n          <Popover open={open} onOpenChange={setOpen}>\n            <PopoverTrigger asChild>\n              <Button\n                variant=\"outline\"\n                role=\"combobox\"\n                aria-expanded={open}\n                className=\"w-full justify-between p-3 h-auto text-left font-normal\"\n              >\n                <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                  <FolderOpen className=\"w-4 h-4 flex-shrink-0\" />\n                  <span className=\"truncate text-sm\">\n                    {workspacePath || t('workspace.default')}\n                  </span>\n                </div>\n                <ChevronDown className=\"ml-2 h-4 w-4 shrink-0 opacity-50\" />\n              </Button>\n            </PopoverTrigger>\n            <PopoverContent className=\"w-full p-0\" align=\"start\">\n              <Command>\n                <CommandInput placeholder={t('workspace.searchPlaceholder')} />\n                <CommandList>\n                  <CommandEmpty>{t('workspace.noResults')}</CommandEmpty>\n                  \n                  {/* 选择新工作区 */}\n                  <CommandGroup heading={t('workspace.actions')}>\n                    <CommandItem\n                      onSelect={() => {\n                        setOpen(false)\n                        handleSelectWorkspace()\n                      }}\n                    >\n                      <FolderOpen className=\"mr-2 h-4 w-4\" />\n                      {t('workspace.select')}\n                    </CommandItem>\n                    {workspacePath && (\n                      <CommandItem\n                        onSelect={() => {\n                          setOpen(false)\n                          handleResetWorkspace()\n                        }}\n                      >\n                        <History className=\"mr-2 h-4 w-4\" />\n                        {t('workspace.reset')}\n                      </CommandItem>\n                    )}\n                  </CommandGroup>\n\n                  {/* 历史路径 */}\n                  {workspaceHistory.length > 0 && (\n                    <>\n                      <CommandSeparator />\n                      <CommandGroup heading={t('workspace.history')}>\n                        {workspaceHistory.map((path, index) => (\n                          <CommandItem\n                            key={index}\n                            onSelect={() => {\n                              setOpen(false)\n                              switchWorkspace(path)\n                            }}\n                          >\n                            <div className=\"flex items-center justify-between w-full group\">\n                              <div className=\"flex items-center gap-2 flex-1 min-w-0\">\n                                <FolderOpen className=\"h-4 w-4 flex-shrink-0\" />\n                                <span className=\"truncate\" title={path}>\n                                  {path}\n                                </span>\n                              </div>\n                              <Button\n                                variant=\"ghost\"\n                                size=\"sm\"\n                                className=\"opacity-0 group-hover:opacity-100 h-6 w-6 p-0 hover:text-destructive\"\n                                onClick={(e) => {\n                                  e.stopPropagation()\n                                  removeWorkspaceHistory(path)\n                                }}\n                              >\n                                <X className=\"h-3 w-3\" />\n                              </Button>\n                            </div>\n                          </CommandItem>\n                        ))}\n                        {workspaceHistory.length > 1 && (\n                          <CommandItem\n                            onSelect={() => {\n                              setOpen(false)\n                              handleClearHistory()\n                            }}\n                            className=\"text-destructive\"\n                          >\n                            <Trash2 className=\"mr-2 h-4 w-4\" />\n                            {t('workspace.clearHistory')}\n                          </CommandItem>\n                        )}\n                      </CommandGroup>\n                    </>\n                  )}\n                </CommandList>\n              </Command>\n            </PopoverContent>\n          </Popover>\n          \n        </div>\n    </FormItem>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/content-text-scale.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Type } from 'lucide-react'\nimport { Slider } from \"@/components/ui/slider\"\nimport useSettingStore from '@/stores/setting'\n\nexport function ContentTextScaleSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { contentTextScale, setContentTextScale } = useSettingStore()\n\n  const handleScaleChange = (value: number[]) => {\n    setContentTextScale(value[0])\n  }\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><Type className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('contentTextScale.title')}</ItemTitle>\n        <ItemDescription>{t('contentTextScale.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <div className=\"space-y-3 w-[180px]\">\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-xs text-muted-foreground\">75%</span>\n            <span className=\"text-xs font-medium\">{contentTextScale}%</span>\n            <span className=\"text-xs text-muted-foreground\">150%</span>\n          </div>\n          <Slider\n            value={[contentTextScale]}\n            onValueChange={handleScaleChange}\n            min={75}\n            max={150}\n            step={1}\n            className=\"w-full\"\n          />\n        </div>\n      </ItemActions>\n    </Item>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/custom-theme.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { useTranslations } from 'next-intl'\nimport { useTheme } from 'next-themes'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Palette, Download, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Textarea } from '@/components/ui/textarea'\nimport useSettingStore from '@/stores/setting'\nimport { HSLValue } from '@/types/theme'\nimport { applyThemeColors, hslToHex } from '@/lib/theme-utils'\nimport { ThemeColorPicker } from './theme-color-picker'\nimport { ThemePresets } from './theme-presets'\n\ninterface ColorScheme {\n  name: string\n  mode?: 'light' | 'dark'\n  colors: {\n    background: string\n    foreground: string\n    card: string\n    cardForeground: string\n    primary: string\n    primaryForeground: string\n    secondary: string\n    secondaryForeground: string\n    third: string\n    thirdForeground: string\n    muted: string\n    mutedForeground: string\n    accent: string\n    accentForeground: string\n    border: string\n    shadow: string\n  }\n}\n\nexport function CustomThemeSettings() {\n  const t = useTranslations('settings.general.interface.customTheme')\n  const { customThemeColors } = useSettingStore()\n  const { setTheme } = useTheme()\n  const [open, setOpen] = useState(false)\n  const [activeTab, setActiveTab] = useState<'custom' | 'presets' | 'import-export'>('custom')\n  const [importCode, setImportCode] = useState('')\n  const [exportCode, setExportCode] = useState('')\n\n  // 实时保存颜色变化\n  const handleColorChange = async (colorKey: string, value: HSLValue | null) => {\n    // 同时更新亮色和暗色主题的颜色\n    const updatedColors = {\n      light: {\n        ...customThemeColors.light,\n        [colorKey]: value,\n      },\n      dark: {\n        ...customThemeColors.dark,\n        [colorKey]: value,\n      },\n    }\n\n    // 立即保存到 store\n    const store = await Store.load('store.json')\n    await store.set('customThemeColors', updatedColors)\n    await store.save()\n\n    // 更新 store 状态（触发 re-render）\n    useSettingStore.setState({ customThemeColors: updatedColors })\n\n    // 立即应用颜色\n    applyThemeColors(updatedColors)\n  }\n\n  // 应用预设方案\n  const applyPreset = async (preset: ColorScheme) => {\n    const hexToHsl = (hex: string): HSLValue | null => {\n      const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n      if (!result) return null\n      const r = parseInt(result[1], 16)\n      const g = parseInt(result[2], 16)\n      const b = parseInt(result[3], 16)\n      const rNorm = r / 255\n      const gNorm = g / 255\n      const bNorm = b / 255\n      const max = Math.max(rNorm, gNorm, bNorm)\n      const min = Math.min(rNorm, gNorm, bNorm)\n      let h = 0, s = 0\n      const l = (max + min) / 2\n      if (max !== min) {\n        const d = max - min\n        s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n        switch (max) {\n          case rNorm: h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; break\n          case gNorm: h = ((bNorm - rNorm) / d + 2) / 6; break\n          case bNorm: h = ((rNorm - gNorm) / d + 4) / 6; break\n        }\n      }\n      return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]\n    }\n\n    const updatedColors = {\n      light: {\n        background: hexToHsl(preset.colors.background),\n        foreground: hexToHsl(preset.colors.foreground),\n        card: hexToHsl(preset.colors.card),\n        cardForeground: hexToHsl(preset.colors.cardForeground),\n        primary: hexToHsl(preset.colors.primary),\n        primaryForeground: hexToHsl(preset.colors.primaryForeground),\n        secondary: hexToHsl(preset.colors.secondary),\n        secondaryForeground: hexToHsl(preset.colors.secondaryForeground),\n        third: hexToHsl(preset.colors.third),\n        thirdForeground: hexToHsl(preset.colors.thirdForeground),\n        muted: hexToHsl(preset.colors.muted),\n        mutedForeground: hexToHsl(preset.colors.mutedForeground),\n        accent: hexToHsl(preset.colors.accent),\n        accentForeground: hexToHsl(preset.colors.accentForeground),\n        border: hexToHsl(preset.colors.border),\n        shadow: hexToHsl(preset.colors.shadow),\n      },\n      dark: {\n        background: hexToHsl(preset.colors.background),\n        foreground: hexToHsl(preset.colors.foreground),\n        card: hexToHsl(preset.colors.card),\n        cardForeground: hexToHsl(preset.colors.cardForeground),\n        primary: hexToHsl(preset.colors.primary),\n        primaryForeground: hexToHsl(preset.colors.primaryForeground),\n        secondary: hexToHsl(preset.colors.secondary),\n        secondaryForeground: hexToHsl(preset.colors.secondaryForeground),\n        third: hexToHsl(preset.colors.third),\n        thirdForeground: hexToHsl(preset.colors.thirdForeground),\n        muted: hexToHsl(preset.colors.muted),\n        mutedForeground: hexToHsl(preset.colors.mutedForeground),\n        accent: hexToHsl(preset.colors.accent),\n        accentForeground: hexToHsl(preset.colors.accentForeground),\n        border: hexToHsl(preset.colors.border),\n        shadow: hexToHsl(preset.colors.shadow),\n      },\n    }\n\n    const store = await Store.load('store.json')\n    await store.set('customThemeColors', updatedColors)\n    await store.save()\n    useSettingStore.setState({ customThemeColors: updatedColors })\n    applyThemeColors(updatedColors)\n\n    // 同时设置系统主题模式\n    if (preset.mode) {\n      setTheme(preset.mode)\n    }\n  }\n\n  // 重置为默认主题\n  const handleResetDefault = async () => {\n    await useSettingStore.getState().resetCustomThemeColors()\n  }\n\n  // 生成导出代码\n  const handleExport = () => {\n    const exportData = {\n      name: 'Custom Theme',\n      colors: {\n        background: hslToHex(customThemeColors.light.background || [0, 0, 100]),\n        foreground: hslToHex(customThemeColors.light.foreground || [0, 0, 0]),\n        card: hslToHex(customThemeColors.light.card || [0, 0, 100]),\n        cardForeground: hslToHex(customThemeColors.light.cardForeground || [0, 0, 0]),\n        primary: hslToHex(customThemeColors.light.primary || [0, 0, 0]),\n        primaryForeground: hslToHex(customThemeColors.light.primaryForeground || [0, 0, 100]),\n        secondary: hslToHex(customThemeColors.light.secondary || [0, 0, 100]),\n        secondaryForeground: hslToHex(customThemeColors.light.secondaryForeground || [0, 0, 0]),\n        third: hslToHex(customThemeColors.light.third || [240, 4.8, 90.9]),\n        thirdForeground: hslToHex(customThemeColors.light.thirdForeground || [240, 5.9, 15]),\n        muted: hslToHex(customThemeColors.light.muted || [0, 0, 100]),\n        mutedForeground: hslToHex(customThemeColors.light.mutedForeground || [0, 0, 50]),\n        accent: hslToHex(customThemeColors.light.accent || [0, 0, 100]),\n        accentForeground: hslToHex(customThemeColors.light.accentForeground || [0, 0, 0]),\n        border: hslToHex(customThemeColors.light.border || [0, 0, 90]),\n        shadow: hslToHex(customThemeColors.light.shadow || [0, 0, 0]),\n      },\n    }\n    setExportCode(JSON.stringify(exportData, null, 2))\n  }\n\n  // 导入配色方案\n  const handleImport = async () => {\n    try {\n      const importData = JSON.parse(importCode) as ColorScheme\n      if (importData.colors) {\n        await applyPreset(importData)\n        setImportCode('')\n        setActiveTab('custom')\n      }\n    } catch (error) {\n      console.error('Import failed:', error)\n    }\n  }\n\n  return (\n    <>\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\"><Palette className=\"size-4\" /></ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('title')}</ItemTitle>\n          <ItemDescription>{t('desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <Button variant=\"outline\" size=\"sm\" onClick={() => setOpen(true)}>\n            {t('button')}\n          </Button>\n        </ItemActions>\n      </Item>\n\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogContent className=\"max-w-4xl max-h-[85vh] overflow-y-auto\">\n          <DialogHeader>\n            <DialogTitle>{t('dialogTitle')}</DialogTitle>\n            <DialogDescription>{t('dialogDesc')}</DialogDescription>\n          </DialogHeader>\n\n          <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'custom' | 'presets' | 'import-export')} className=\"mt-4\">\n            <TabsList className=\"grid w-full grid-cols-3\">\n              <TabsTrigger value=\"custom\">{t('tabs.custom')}</TabsTrigger>\n              <TabsTrigger value=\"presets\">{t('tabs.presets')}</TabsTrigger>\n              <TabsTrigger value=\"import-export\">{t('tabs.importExport')}</TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"custom\" className=\"mt-4\">\n              <ThemeColorPicker\n                colors={customThemeColors.light}\n                onColorChange={handleColorChange}\n                t={t}\n              />\n            </TabsContent>\n\n            <TabsContent value=\"presets\" className=\"mt-4\">\n              <ThemePresets onApplyPreset={applyPreset} onResetDefault={handleResetDefault} t={t} />\n            </TabsContent>\n\n            <TabsContent value=\"import-export\" className=\"mt-4 space-y-4\">\n              {/* 导出 */}\n              <div>\n                <div className=\"flex items-center justify-between mb-2\">\n                  <h3 className=\"text-sm font-semibold\">{t('export.title')}</h3>\n                  <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n                    <Download className=\"h-4 w-4 mr-1\" />\n                    {t('export.button')}\n                  </Button>\n                </div>\n                <Textarea\n                  value={exportCode}\n                  onChange={(e) => setExportCode(e.target.value)}\n                  placeholder={t('export.placeholder')}\n                  className=\"font-mono text-xs\"\n                  rows={8}\n                  readOnly\n                />\n              </div>\n\n              {/* 导入 */}\n              <div>\n                <div className=\"flex items-center justify-between mb-2\">\n                  <h3 className=\"text-sm font-semibold\">{t('import.title')}</h3>\n                  <Button variant=\"outline\" size=\"sm\" onClick={handleImport} disabled={!importCode.trim()}>\n                    <Upload className=\"h-4 w-4 mr-1\" />\n                    {t('import.button')}\n                  </Button>\n                </div>\n                <Textarea\n                  value={importCode}\n                  onChange={(e) => setImportCode(e.target.value)}\n                  placeholder={t('import.placeholder')}\n                  className=\"font-mono text-xs\"\n                  rows={8}\n                />\n              </div>\n            </TabsContent>\n          </Tabs>\n        </DialogContent>\n      </Dialog>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/file-manager-text-size.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Folder } from 'lucide-react'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport useSettingStore from '@/stores/setting'\n\nconst textSizeOptions = [\n  { value: 'xs', label: 'XS', desc: '12px' },\n  { value: 'sm', label: 'SM', desc: '14px' },\n  { value: 'md', label: 'MD', desc: '16px' },\n  { value: 'lg', label: 'LG', desc: '18px' },\n  { value: 'xl', label: 'XL', desc: '20px' },\n]\n\nexport function FileManagerTextSizeSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { fileManagerTextSize, setFileManagerTextSize } = useSettingStore()\n\n  const handleSizeChange = (value: string) => {\n    setFileManagerTextSize(value)\n  }\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><Folder className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('fileManagerTextSize.title')}</ItemTitle>\n        <ItemDescription>{t('fileManagerTextSize.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Select value={fileManagerTextSize} onValueChange={handleSizeChange}>\n          <SelectTrigger className=\"w-[160px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {textSizeOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                <span className=\"text-center w-full\">{option.desc}</span>\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/index.tsx",
    "content": "'use client'\nimport { ThemeSettings } from './theme'\nimport { LanguageSettings } from './language'\nimport { ScaleSettings } from './scale'\nimport { ContentTextScaleSettings } from './content-text-scale'\nimport { FileManagerTextSizeSettings } from './file-manager-text-size'\nimport { RecordTextSizeSettings } from './record-text-size'\nimport { CustomThemeSettings } from './custom-theme'\n\nexport function InterfaceSettings() {\n\n  return (\n    <div className=\"space-y-4\">\n      <ThemeSettings />\n      <LanguageSettings />\n      <ScaleSettings />\n      <ContentTextScaleSettings />\n      <FileManagerTextSizeSettings />\n      <RecordTextSizeSettings />\n      <CustomThemeSettings />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/language.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Languages } from 'lucide-react'\nimport { useI18n } from \"@/hooks/useI18n\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\n\nexport function LanguageSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { currentLocale, changeLanguage } = useI18n()\n\n  const getLanguageDisplay = (locale: string) => {\n    switch (locale) {\n      case \"en\":\n        return \"English\"\n      case \"zh\":\n        return \"中文\"\n      case \"zh-TW\":\n        return \"繁體中文\"\n      case \"pt-BR\":\n        return \"Português\"\n      case \"ja\":\n        return \"日本語\"\n      default:\n        return \"中文\"\n    }\n  }\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><Languages className=\"h-4 w-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('language.title')}</ItemTitle>\n        <ItemDescription>{t('language.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Select value={currentLocale} onValueChange={changeLanguage}>\n          <SelectTrigger className=\"w-[180px]\">\n            <SelectValue>\n              <div className=\"flex items-center gap-2\">\n                <span>{getLanguageDisplay(currentLocale)}</span>\n              </div>\n            </SelectValue>\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"zh\">\n              <div className=\"flex items-center gap-2\">\n                <span>中文</span>\n              </div>\n            </SelectItem>\n            <SelectItem value=\"zh-TW\">\n              <div className=\"flex items-center gap-2\">\n                <span>繁體中文</span>\n              </div>\n            </SelectItem>\n            <SelectItem value=\"en\">\n              <div className=\"flex items-center gap-2\">\n                <span>English</span>\n              </div>\n            </SelectItem>\n            <SelectItem value=\"ja\">\n              <div className=\"flex items-center gap-2\">\n                <span>日本語</span>\n              </div>\n            </SelectItem>\n            <SelectItem value=\"pt-BR\">\n              <div className=\"flex items-center gap-2\">\n                <span>Português</span>\n              </div>\n            </SelectItem>\n          </SelectContent>\n        </Select>\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/record-text-size.tsx",
    "content": "'use client'\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Bookmark } from 'lucide-react'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport useSettingStore from '@/stores/setting'\n\nconst textSizeOptions = [\n  { value: 'xs', label: 'XS', desc: '12px' },\n  { value: 'sm', label: 'SM', desc: '14px' },\n  { value: 'md', label: 'MD', desc: '16px' },\n  { value: 'lg', label: 'LG', desc: '18px' },\n  { value: 'xl', label: 'XL', desc: '20px' },\n]\n\nexport function RecordTextSizeSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { recordTextSize, setRecordTextSize } = useSettingStore()\n\n  const handleSizeChange = (value: string) => {\n    setRecordTextSize(value)\n  }\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><Bookmark className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('recordTextSize.title')}</ItemTitle>\n        <ItemDescription>{t('recordTextSize.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Select value={recordTextSize} onValueChange={handleSizeChange}>\n          <SelectTrigger className=\"w-[160px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {textSizeOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                <span className=\"text-center w-full\">{option.desc}</span>\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/scale.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { ZoomIn } from 'lucide-react'\nimport { Slider } from \"@/components/ui/slider\"\nimport useSettingStore from '@/stores/setting'\nimport { useEffect } from 'react'\n\nexport function ScaleSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { uiScale, setUiScale } = useSettingStore()\n\n  // 初始化时应用缩放\n  useEffect(() => {\n    document.documentElement.style.fontSize = `${uiScale}%`\n  }, [])\n\n  const handleScaleChange = (value: number[]) => {\n    setUiScale(value[0])\n  }\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><ZoomIn className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('scale.title')}</ItemTitle>\n        <ItemDescription>{t('scale.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <div className=\"space-y-3 w-[180px]\">\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-xs text-muted-foreground\">75%</span>\n            <span className=\"text-xs font-medium\">{uiScale}%</span>\n            <span className=\"text-xs text-muted-foreground\">150%</span>\n          </div>\n          <Slider\n            value={[uiScale]}\n            onValueChange={handleScaleChange}\n            min={75}\n            max={150}\n            step={1}\n            className=\"w-full\"\n          />\n        </div>\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/theme-color-picker.tsx",
    "content": "'use client'\n\nimport { Label } from '@/components/ui/label'\nimport { Button } from '@/components/ui/button'\nimport { HSLValue } from '@/types/theme'\nimport { hexToHsl, hslToHex } from '@/lib/theme-utils'\nimport { RotateCcw } from 'lucide-react'\n\ninterface ThemeColorPickerProps {\n  colors: {\n    background: HSLValue | null\n    foreground: HSLValue | null\n    card: HSLValue | null\n    cardForeground: HSLValue | null\n    primary: HSLValue | null\n    primaryForeground: HSLValue | null\n    secondary: HSLValue | null\n    secondaryForeground: HSLValue | null\n    third: HSLValue | null\n    thirdForeground: HSLValue | null\n    muted: HSLValue | null\n    mutedForeground: HSLValue | null\n    accent: HSLValue | null\n    accentForeground: HSLValue | null\n    border: HSLValue | null\n    shadow: HSLValue | null\n  }\n  onColorChange: (colorKey: string, value: HSLValue | null) => void\n  t: (key: string) => string\n}\n\nexport function ThemeColorPicker({ colors, onColorChange, t }: ThemeColorPickerProps) {\n  const colorConfig: Array<{ key: string; label: string; defaultColor: string }> = [\n    { key: 'background', label: t('colors.background'), defaultColor: '#ffffff' },\n    { key: 'foreground', label: t('colors.foreground'), defaultColor: '#0a0a0a' },\n    { key: 'card', label: t('colors.card'), defaultColor: '#ffffff' },\n    { key: 'cardForeground', label: t('colors.cardForeground'), defaultColor: '#0a0a0a' },\n    { key: 'primary', label: t('colors.primary'), defaultColor: '#171717' },\n    { key: 'primaryForeground', label: t('colors.primaryForeground'), defaultColor: '#fafafa' },\n    { key: 'secondary', label: t('colors.secondary'), defaultColor: '#f5f5f5' },\n    { key: 'secondaryForeground', label: t('colors.secondaryForeground'), defaultColor: '#171717' },\n    { key: 'third', label: t('colors.third'), defaultColor: '#e5e5e5' },\n    { key: 'thirdForeground', label: t('colors.thirdForeground'), defaultColor: '#262626' },\n    { key: 'muted', label: t('colors.muted'), defaultColor: '#f5f5f5' },\n    { key: 'mutedForeground', label: t('colors.mutedForeground'), defaultColor: '#737373' },\n    { key: 'accent', label: t('colors.accent'), defaultColor: '#f5f5f5' },\n    { key: 'accentForeground', label: t('colors.accentForeground'), defaultColor: '#171717' },\n    { key: 'border', label: t('colors.border'), defaultColor: '#e5e5e5' },\n    { key: 'shadow', label: t('colors.shadow'), defaultColor: '#000000' },\n  ]\n\n  // 分成两列\n  const half = Math.ceil(colorConfig.length / 2)\n  const leftColumn = colorConfig.slice(0, half)\n  const rightColumn = colorConfig.slice(half)\n\n  return (\n    <div className=\"grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2\">\n      <div className=\"space-y-2\">\n        {leftColumn.map((config) => (\n          <ColorInput\n            key={config.key}\n            label={config.label}\n            value={colors[config.key as keyof typeof colors]}\n            defaultColor={config.defaultColor}\n            onChange={(value) => onColorChange(config.key, value)}\n          />\n        ))}\n      </div>\n      <div className=\"space-y-2\">\n        {rightColumn.map((config) => (\n          <ColorInput\n            key={config.key}\n            label={config.label}\n            value={colors[config.key as keyof typeof colors]}\n            defaultColor={config.defaultColor}\n            onChange={(value) => onColorChange(config.key, value)}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n\ninterface ColorInputProps {\n  label: string\n  value: [number, number, number] | null\n  defaultColor: string\n  onChange: (value: HSLValue | null) => void\n}\n\nfunction ColorInput({ label, value, defaultColor, onChange }: ColorInputProps) {\n  const hexValue = value ? hslToHex(value) : defaultColor\n  const hasCustomValue = value !== null\n\n  const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const newHex = e.target.value\n\n    if (!newHex) {\n      onChange(null)\n      return\n    }\n\n    const hsl = hexToHsl(newHex)\n    if (hsl) {\n      onChange(hsl)\n    }\n  }\n\n  const handleReset = () => {\n    onChange(null)\n  }\n\n  return (\n    <div className=\"flex items-center gap-2 py-1\">\n      {/* 颜色选择器 */}\n      <input\n        type=\"color\"\n        value={hexValue}\n        onChange={handleColorChange}\n        className=\"w-8 h-8 rounded cursor-pointer border-2 border-border hover:border-primary transition-colors shrink-0\"\n        title=\"点击选择颜色\"\n      />\n\n      {/* 标签 */}\n      <Label className=\"text-xs font-medium flex-1 cursor-pointer\" title={label}>\n        {label}\n      </Label>\n\n      {/* 颜色值 - 移动端隐藏 */}\n      <span className=\"hidden md:inline text-xs text-muted-foreground font-mono w-16 text-right tabular-nums\">\n        {hexValue}\n      </span>\n\n      {/* 重置按钮 - 始终占位 */}\n      <div className=\"h-6 w-6 shrink-0 flex items-center justify-center\">\n        {hasCustomValue ? (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-6 w-6 p-0\"\n            onClick={handleReset}\n            title=\"恢复默认值\"\n          >\n            <RotateCcw className=\"h-3 w-3\" />\n          </Button>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/theme-presets.tsx",
    "content": "'use client'\n\nimport { RotateCcw } from 'lucide-react'\n\ninterface ColorScheme {\n  name: string\n  mode: 'light' | 'dark'\n  colors: {\n    background: string\n    foreground: string\n    card: string\n    cardForeground: string\n    primary: string\n    primaryForeground: string\n    secondary: string\n    secondaryForeground: string\n    third: string\n    thirdForeground: string\n    muted: string\n    mutedForeground: string\n    accent: string\n    accentForeground: string\n    border: string\n    shadow: string\n  }\n  isReset?: boolean\n}\n\ninterface ThemePresetsProps {\n  onApplyPreset: (preset: ColorScheme) => void\n  onResetDefault?: () => void\n  t: (key: string) => string\n}\n\nexport function ThemePresets({ onApplyPreset, onResetDefault, t }: ThemePresetsProps) {\n  const presets: ColorScheme[] = [\n    {\n      name: t('presets.reset.name'),\n      mode: 'light',\n      isReset: true,\n      colors: {\n        background: '#ffffff',\n        foreground: '#0a0a0a',\n        card: '#ffffff',\n        cardForeground: '#0a0a0a',\n        primary: '#171717',\n        primaryForeground: '#fafafa',\n        secondary: '#f5f5f5',\n        secondaryForeground: '#171717',\n        third: '#e5e5e5',\n        thirdForeground: '#262626',\n        muted: '#f5f5f5',\n        mutedForeground: '#737373',\n        accent: '#f5f5f5',\n        accentForeground: '#171717',\n        border: '#e5e5e5',\n        shadow: '#000000',\n      },\n    },\n    {\n      name: t('presets.ocean.name'),\n      mode: 'light',\n      colors: {\n        background: '#f0f9ff',\n        foreground: '#0c4a6e',\n        card: '#ffffff',\n        cardForeground: '#0c4a6e',\n        primary: '#0284c7',\n        primaryForeground: '#ffffff',\n        secondary: '#e0f2fe',\n        secondaryForeground: '#0c4a6e',\n        third: '#bae6fd',\n        thirdForeground: '#0369a1',\n        muted: '#f1f5f9',\n        mutedForeground: '#64748b',\n        accent: '#0ea5e9',\n        accentForeground: '#ffffff',\n        border: '#bae6fd',\n        shadow: '#0c4a6e',\n      },\n    },\n    {\n      name: t('presets.forest.name'),\n      mode: 'light',\n      colors: {\n        background: '#f0fdf4',\n        foreground: '#14532d',\n        card: '#ffffff',\n        cardForeground: '#14532d',\n        primary: '#16a34a',\n        primaryForeground: '#ffffff',\n        secondary: '#dcfce7',\n        secondaryForeground: '#14532d',\n        third: '#bbf7d0',\n        thirdForeground: '#166534',\n        muted: '#f7fee7',\n        mutedForeground: '#4d7c0f',\n        accent: '#22c55e',\n        accentForeground: '#ffffff',\n        border: '#bbf7d0',\n        shadow: '#14532d',\n      },\n    },\n    {\n      name: t('presets.sunset.name'),\n      mode: 'light',\n      colors: {\n        background: '#fef2f2',\n        foreground: '#7f1d1d',\n        card: '#ffffff',\n        cardForeground: '#7f1d1d',\n        primary: '#dc2626',\n        primaryForeground: '#ffffff',\n        secondary: '#fee2e2',\n        secondaryForeground: '#7f1d1d',\n        third: '#fecaca',\n        thirdForeground: '#b91c1c',\n        muted: '#fef2f2',\n        mutedForeground: '#991b1b',\n        accent: '#f87171',\n        accentForeground: '#ffffff',\n        border: '#fecaca',\n        shadow: '#7f1d1d',\n      },\n    },\n    {\n      name: t('presets.lavender.name'),\n      mode: 'light',\n      colors: {\n        background: '#faf5ff',\n        foreground: '#581c87',\n        card: '#ffffff',\n        cardForeground: '#581c87',\n        primary: '#9333ea',\n        primaryForeground: '#ffffff',\n        secondary: '#f3e8ff',\n        secondaryForeground: '#581c87',\n        third: '#e9d5ff',\n        thirdForeground: '#7e22ce',\n        muted: '#faf5ff',\n        mutedForeground: '#7e22ce',\n        accent: '#a855f7',\n        accentForeground: '#ffffff',\n        border: '#e9d5ff',\n        shadow: '#581c87',\n      },\n    },\n    {\n      name: t('presets.midnight.name'),\n      mode: 'dark',\n      colors: {\n        background: '#1a1a2e',\n        foreground: '#eaeaea',\n        card: '#16213e',\n        cardForeground: '#eaeaea',\n        primary: '#0f3460',\n        primaryForeground: '#eaeaea',\n        secondary: '#1f4068',\n        secondaryForeground: '#eaeaea',\n        third: '#0f3460',\n        thirdForeground: '#a0a0a0',\n        muted: '#16213e',\n        mutedForeground: '#a0a0a0',\n        accent: '#e94560',\n        accentForeground: '#ffffff',\n        border: '#0f3460',\n        shadow: '#000000',\n      },\n    },\n    {\n      name: t('presets.deepSea.name'),\n      mode: 'dark',\n      colors: {\n        background: '#0f172a',\n        foreground: '#e2e8f0',\n        card: '#1e293b',\n        cardForeground: '#e2e8f0',\n        primary: '#3b82f6',\n        primaryForeground: '#ffffff',\n        secondary: '#334155',\n        secondaryForeground: '#e2e8f0',\n        third: '#1e3a8a',\n        thirdForeground: '#cbd5e1',\n        muted: '#1e293b',\n        mutedForeground: '#94a3b8',\n        accent: '#60a5fa',\n        accentForeground: '#ffffff',\n        border: '#334155',\n        shadow: '#020617',\n      },\n    },\n    {\n      name: t('presets.darkForest.name'),\n      mode: 'dark',\n      colors: {\n        background: '#0a1f1a',\n        foreground: '#e2e8f0',\n        card: '#142b26',\n        cardForeground: '#e2e8f0',\n        primary: '#22c55e',\n        primaryForeground: '#ffffff',\n        secondary: '#1a3a33',\n        secondaryForeground: '#e2e8f0',\n        third: '#14532d',\n        thirdForeground: '#bbf7d0',\n        muted: '#142b26',\n        mutedForeground: '#86efac',\n        accent: '#4ade80',\n        accentForeground: '#0a1f1a',\n        border: '#1a3a33',\n        shadow: '#052e16',\n      },\n    },\n    {\n      name: t('presets.darkViolet.name'),\n      mode: 'dark',\n      colors: {\n        background: '#1a0b2e',\n        foreground: '#e2e8f0',\n        card: '#2d1b4e',\n        cardForeground: '#e2e8f0',\n        primary: '#a855f7',\n        primaryForeground: '#ffffff',\n        secondary: '#3b2466',\n        secondaryForeground: '#e2e8f0',\n        third: '#581c87',\n        thirdForeground: '#d8b4fe',\n        muted: '#2d1b4e',\n        mutedForeground: '#c4b5fd',\n        accent: '#c084fc',\n        accentForeground: '#1a0b2e',\n        border: '#3b2466',\n        shadow: '#2e1065',\n      },\n    },\n    {\n      name: t('presets.coralWarm.name'),\n      mode: 'light',\n      colors: {\n        background: '#fff7ed',\n        foreground: '#431407',\n        card: '#ffffff',\n        cardForeground: '#431407',\n        primary: '#ea580c',\n        primaryForeground: '#ffffff',\n        secondary: '#ffedd5',\n        secondaryForeground: '#431407',\n        third: '#fed7aa',\n        thirdForeground: '#c2410c',\n        muted: '#fed7aa',\n        mutedForeground: '#9a3412',\n        accent: '#fb923c',\n        accentForeground: '#ffffff',\n        border: '#fed7aa',\n        shadow: '#431407',\n      },\n    },\n    {\n      name: t('presets.slateGray.name'),\n      mode: 'light',\n      colors: {\n        background: '#f8fafc',\n        foreground: '#1e293b',\n        card: '#ffffff',\n        cardForeground: '#1e293b',\n        primary: '#475569',\n        primaryForeground: '#ffffff',\n        secondary: '#e2e8f0',\n        secondaryForeground: '#1e293b',\n        third: '#cbd5e1',\n        thirdForeground: '#334155',\n        muted: '#f1f5f9',\n        mutedForeground: '#64748b',\n        accent: '#64748b',\n        accentForeground: '#ffffff',\n        border: '#e2e8f0',\n        shadow: '#0f172a',\n      },\n    },\n    {\n      name: t('presets.darkGold.name'),\n      mode: 'dark',\n      colors: {\n        background: '#1a1915',\n        foreground: '#e2e8f0',\n        card: '#2a2924',\n        cardForeground: '#e2e8f0',\n        primary: '#fbbf24',\n        primaryForeground: '#1a1915',\n        secondary: '#3a3934',\n        secondaryForeground: '#e2e8f0',\n        third: '#78350f',\n        thirdForeground: '#fde68a',\n        muted: '#2a2924',\n        mutedForeground: '#fcd34d',\n        accent: '#f59e0b',\n        accentForeground: '#1a1915',\n        border: '#3a3934',\n        shadow: '#000000',\n      },\n    },\n    {\n      name: t('presets.beigeWarm.name'),\n      mode: 'light',\n      colors: {\n        background: '#fef9f3',\n        foreground: '#4a3f35',\n        card: '#ffffff',\n        cardForeground: '#4a3f35',\n        primary: '#c9a66b',\n        primaryForeground: '#ffffff',\n        secondary: '#f5ebe0',\n        secondaryForeground: '#4a3f35',\n        third: '#ede0d4',\n        thirdForeground: '#5c4d3f',\n        muted: '#f5ebe0',\n        mutedForeground: '#8b7355',\n        accent: '#d4a574',\n        accentForeground: '#ffffff',\n        border: '#ede0d4',\n        shadow: '#4a3f35',\n      },\n    },\n    {\n      name: t('presets.beigeDark.name'),\n      mode: 'dark',\n      colors: {\n        background: '#1a1814',\n        foreground: '#e8e0d8',\n        card: '#24201a',\n        cardForeground: '#e8e0d8',\n        primary: '#c9a66b',\n        primaryForeground: '#1a1814',\n        secondary: '#2e2a22',\n        secondaryForeground: '#e8e0d8',\n        third: '#3a342a',\n        thirdForeground: '#d4c4b4',\n        muted: '#24201a',\n        mutedForeground: '#a89888',\n        accent: '#d4a574',\n        accentForeground: '#1a1814',\n        border: '#2e2a22',\n        shadow: '#0d0c0a',\n      },\n    },\n  ]\n\n  return (\n    <div className=\"grid grid-cols-2 md:grid-cols-3 gap-4\">\n      {presets.map((preset) => (\n        <div\n          key={preset.name}\n          role=\"button\"\n          tabIndex={0}\n          onClick={() => {\n            if (preset.isReset && onResetDefault) {\n              onResetDefault()\n            } else {\n              onApplyPreset(preset)\n            }\n          }}\n          onKeyDown={(e) => {\n            if (e.key === 'Enter' || e.key === ' ') {\n              e.preventDefault()\n              if (preset.isReset && onResetDefault) {\n                onResetDefault()\n              } else {\n                onApplyPreset(preset)\n              }\n            }\n          }}\n          className={`group relative flex flex-col items-center gap-3 p-4 rounded-lg border-2 transition-all cursor-pointer ${\n            preset.isReset\n              ? 'border-dashed border-muted-foreground/50 hover:border-primary'\n              : 'border-border hover:border-primary'\n          }`}\n        >\n          {/* 恢复默认图标 - 只对第一个显示 */}\n          {preset.isReset && (\n            <RotateCcw className=\"w-4 h-4\" />\n          )}\n\n          {/* 颜色预览条 - 恢复默认不显示 */}\n          {!preset.isReset && (\n            <div className=\"flex w-full h-3 rounded-full overflow-hidden\">\n              <div className=\"flex-1\" style={{ backgroundColor: preset.colors.background }} />\n              <div className=\"flex-1\" style={{ backgroundColor: preset.colors.foreground }} />\n              <div className=\"flex-1\" style={{ backgroundColor: preset.colors.primary }} />\n              <div className=\"flex-1\" style={{ backgroundColor: preset.colors.secondary }} />\n              <div className=\"flex-1\" style={{ backgroundColor: preset.colors.accent }} />\n            </div>\n          )}\n\n          {/* 标签和名称 */}\n          <div className=\"flex items-center gap-2\">\n            {!preset.isReset && (\n              <span className=\"text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\">\n                {preset.mode === 'light' ? 'Light' : 'Dark'}\n              </span>\n            )}\n            <span className=\"text-sm font-medium\">{preset.name}</span>\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/interface-settings/theme.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Palette, Moon, Sun, SunMoon } from 'lucide-react'\nimport { useTheme } from \"next-themes\"\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\n\nexport function ThemeSettings() {\n  const t = useTranslations('settings.general.interface')\n  const { theme, setTheme } = useTheme()\n\n  return (\n    <Item variant=\"outline\">\n      <ItemMedia variant=\"icon\"><Palette className=\"size-4\" /></ItemMedia>\n      <ItemContent>\n        <ItemTitle>{t('theme.title')}</ItemTitle>\n        <ItemDescription>{t('theme.desc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Tabs value={theme || 'system'} onValueChange={setTheme}>\n          <TabsList className=\"grid w-full grid-cols-3\">\n            <TabsTrigger value=\"light\" className=\"flex items-center gap-2\">\n              <Sun className=\"size-4\" />\n            </TabsTrigger>\n            <TabsTrigger value=\"dark\" className=\"flex items-center gap-2\">\n              <Moon className=\"size-4\" />\n            </TabsTrigger>\n            <TabsTrigger value=\"system\" className=\"flex items-center gap-2\">\n              <SunMoon className=\"size-4\" />\n            </TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </ItemActions>\n    </Item>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { SettingType } from '../components/setting-base'\nimport { Settings } from 'lucide-react'\nimport { InterfaceSettings } from './interface-settings'\n\nexport default function GeneralSettingsPage() {\n  const t = useTranslations('settings.general')\n\n  return (\n    <SettingType\n      id=\"general\"\n      title={t('title')}\n      desc={t('desc')}\n      icon={<Settings className=\"size-4 lg:size-6\" />}\n    >\n      <InterfaceSettings />\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/general/tool-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\n\nexport function ToolSettings() {\n  const t = useTranslations('settings.general')\n\n  return (\n    <div className=\"space-y-4\">\n      <h2 className=\"text-lg font-semibold\">{t('tools.title')}</h2>\n      <p className=\"text-sm text-muted-foreground\">{t('tools.desc')}</p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/imageHosting/github.tsx",
    "content": "'use client'\nimport { Input } from \"@/components/ui/input\";\nimport { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslations } from 'next-intl';\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport dayjs from \"dayjs\";\nimport relativeTime from \"dayjs/plugin/relativeTime\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { getUserInfo } from \"@/lib/sync/github\";\nimport { RepoNames, SyncStateEnum } from \"@/lib/sync/github.types\";\nimport useImageStore from \"@/stores/imageHosting\";\nimport { createImageRepo, checkImageRepoState } from \"@/lib/imageHosting/github\";\nimport { getImageRepoName } from \"@/lib/sync/repo-utils\";\nimport { Avatar, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\n\ndayjs.extend(relativeTime)\n\nexport function GithubImageHosting() {\n\n  const t = useTranslations();\n  const { setImageRepoUserInfo } = useImageStore()\n  const [accessTokenVisible, setAccessTokenVisible] = useState(false)\n\n  const {\n    githubImageAccessToken,\n    setGithubImageAccessToken,\n    useImageRepo,\n    jsdelivr,\n    setJsdelivr,\n    githubCustomImageRepo,\n    setGithubCustomImageRepo,\n  } = useSettingStore()\n  const {\n    imageRepoState,\n    setImageRepoState,\n    imageRepoInfo,\n    setImageRepoInfo,\n  } = useImageStore()\n\n  // 检查按钮是否禁用\n  const isChecking = imageRepoState === SyncStateEnum.checking;\n  const isCreating = imageRepoState === SyncStateEnum.creating;\n\n  // 创建 GitHub 仓库\n  async function createGithubRepo() {\n    try {\n      setImageRepoState(SyncStateEnum.creating)\n      const actualRepoName = await getImageRepoName()\n      const info = await createImageRepo(actualRepoName)\n      if (info) {\n        setImageRepoInfo(info)\n        setImageRepoState(SyncStateEnum.success)\n      } else {\n        setImageRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to create GitHub repo:', err)\n      setImageRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  // 检查 GitHub 仓库状态\n  async function checkGithubRepos() {\n    try {\n      setImageRepoState(SyncStateEnum.checking)\n      const store = await Store.load('store.json');\n      const accessToken = await store.get<string>('githubImageAccessToken')\n      const userInfo = await getUserInfo(accessToken);\n      if (!userInfo) {\n        setImageRepoState(SyncStateEnum.fail)\n        return;\n      }\n      setImageRepoUserInfo(userInfo)\n      // 获取实际使用的仓库名（自定义或默认）\n      const actualRepoName = await getImageRepoName()\n      // 检查图床仓库状态\n      const imageRepo = await checkImageRepoState(actualRepoName)\n      if (imageRepo) {\n        setImageRepoInfo(imageRepo)\n        setImageRepoState(SyncStateEnum.success)\n      } else {\n        setImageRepoInfo(undefined)\n        setImageRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check GitHub repos:', err)\n      setImageRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  async function tokenChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    if (value === '') {\n      setImageRepoState(SyncStateEnum.fail)\n      setImageRepoInfo(undefined)\n    }\n    await setGithubImageAccessToken(value)\n    if (value) {\n      checkGithubRepos()\n    }\n  }\n\n  async function customRepoChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    await setGithubCustomImageRepo(value)\n    // 如果有token，重新检查仓库状态\n    if (githubImageAccessToken) {\n      checkGithubRepos()\n    }\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const token = await store.get<string>('githubImageAccessToken')\n      if (token) {\n        await setGithubImageAccessToken(token)\n        checkGithubRepos()\n      } else {\n        await setGithubImageAccessToken('')\n      }\n    }\n    init()\n  }, [])\n\n  const getStatusIcon = () => {\n    switch (imageRepoState) {\n      case SyncStateEnum.success:\n        return <CheckCircle className=\"size-4 text-green-500\" />;\n      case SyncStateEnum.checking:\n        return <Loader2 className=\"size-4 animate-spin text-blue-500\" />;\n      case SyncStateEnum.creating:\n        return <Loader2 className=\"size-4 animate-spin text-yellow-500\" />;\n      case SyncStateEnum.fail:\n      default:\n        return <XCircle className=\"size-4 text-red-500\" />;\n    }\n  };\n\n  const getStatusText = () => {\n    switch (imageRepoState) {\n      case SyncStateEnum.success:\n        return t('settings.imageHosting.github.repoExists');\n      case SyncStateEnum.checking:\n        return t('settings.imageHosting.github.checking');\n      case SyncStateEnum.creating:\n        return t('settings.imageHosting.github.creating');\n      case SyncStateEnum.fail:\n      default:\n        return t('settings.imageHosting.github.repoNotExists');\n    }\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>GitHub 图床</CardTitle>\n            <CardDescription>\n              使用 GitHub 仓库作为图片存储服务\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">{t('settings.imageHosting.github.repoStatus')}</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n        {/* 仓库操作按钮 */}\n        {githubImageAccessToken && imageRepoState === SyncStateEnum.fail && (\n          <div className=\"flex gap-2\">\n            <Button \n              onClick={createGithubRepo}\n              size=\"sm\"\n              disabled={isCreating || isChecking}\n            >\n              {isCreating ? '创建中...' : '创建仓库'}\n            </Button>\n            <Button \n              onClick={checkGithubRepos}\n              size=\"sm\"\n              variant=\"outline\"\n              disabled={isChecking || isCreating}\n            >\n              {isChecking ? '检测中...' : '重新检测'}\n            </Button>\n          </div>\n        )}\n\n        {/* 自定义仓库名 */}\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium\">自定义图床仓库名</label>\n          <p className=\"text-xs text-muted-foreground\">留空则使用默认仓库名 &quot;{RepoNames.image}&quot;</p>\n          <Input \n            value={githubCustomImageRepo} \n            onChange={customRepoChangeHandler}\n            placeholder={`默认: ${RepoNames.image}`}\n          />\n        </div>\n\n        {/* Access Token 配置 */}\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium\">GitHub Access Token</label>\n          <p className=\"text-xs text-muted-foreground\">{t('settings.sync.newTokenDesc')}</p>\n          <div className=\"flex gap-2\">\n            <Input\n              value={githubImageAccessToken}\n              onChange={tokenChangeHandler}\n              type={accessTokenVisible ? 'text' : 'password'}\n              placeholder=\"输入 GitHub Access Token\"\n            />\n            <Button variant=\"outline\" size=\"icon\" onClick={() => setAccessTokenVisible(!accessTokenVisible)}>\n              {accessTokenVisible ? <Eye /> : <EyeOff />}\n            </Button>\n          </div>\n          <OpenBroswer url=\"https://github.com/settings/tokens/new\" title={t('settings.sync.newToken')} className=\"text-sm text-blue-500 hover:underline\" />\n        </div>\n\n        {/* 仓库信息 */}\n        {imageRepoInfo && (\n          <div className=\"space-y-2\">\n            <label className=\"text-sm font-medium\">{t('settings.sync.repoStatus')}</label>\n            <div className=\"p-4 border rounded-lg\">\n              <div className=\"flex items-center gap-4\">\n                <Avatar className=\"size-12\">\n                  <AvatarImage src={imageRepoInfo?.owner.avatar_url || ''} />\n                </Avatar>\n                <div>\n                  <h3 className=\"text-lg font-semibold flex items-center gap-2 mb-1\">\n                    <OpenBroswer title={imageRepoInfo?.full_name || ''} url={imageRepoInfo?.html_url || ''} />\n                  </h3>\n                  <p className=\"text-sm text-muted-foreground\">\n                    {t('settings.sync.createdAt', { time: dayjs(imageRepoInfo?.created_at).fromNow() })}，\n                    {t('settings.sync.updatedAt', { time: dayjs(imageRepoInfo?.updated_at).fromNow() })}\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n        )}\n\n        {/* JSDelivr 设置 */}\n        {imageRepoInfo && (\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <div>\n                <label className=\"text-sm font-medium\">{t('settings.sync.jsdelivrSetting')}</label>\n                <p className=\"text-xs text-muted-foreground\">{t('settings.sync.jsdelivrSettingDesc')}</p>\n              </div>\n              <Switch \n                checked={jsdelivr} \n                onCheckedChange={(checked) => setJsdelivr(checked)} \n                disabled={!githubImageAccessToken || imageRepoState !== SyncStateEnum.success || !useImageRepo}\n              />\n            </div>\n          </div>\n        )}\n\n      </CardContent>\n    </Card>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageHosting/page.tsx",
    "content": "'use client';\nimport { ImageUp } from \"lucide-react\"\nimport { useTranslations } from 'next-intl';\nimport { SettingType } from '../components/setting-base';\nimport { GithubImageHosting } from \"./github\";\nimport SMMSImageHosting from \"./smms\";\nimport useImageStore from \"@/stores/imageHosting\";\nimport { useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport PicgoImageHosting from \"./picgo\";\nimport { S3ImageHosting } from \"./s3\";\nimport { SettingSwitch } from \"./setting-switch\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport useSettingStore from \"@/stores/setting\"\n\nexport default function ImageHostingPage() {\n  const t = useTranslations();\n  const { mainImageHosting, setMainImageHosting } = useImageStore()\n  const { useImageRepo } = useSettingStore()\n\n  // 使用 mainImageHosting 作为受控值\n  const currentValue = mainImageHosting || 'github'\n\n  const handleValueChange = async (value: string) => {\n    await setMainImageHosting(value)\n  }\n\n  useEffect(() => {\n    // 初始化时从 store 加载\n    const init = async () => {\n      const store = await Store.load('store.json');\n      const imageHosting = await store.get<string>('mainImageHosting')\n      if (imageHosting) {\n        await setMainImageHosting(imageHosting)\n      }\n    }\n    init()\n  }, [])\n\n  return (\n    <SettingType id=\"imageHosting\" icon={<ImageUp />} title={t('settings.imageHosting.title')} desc={t('settings.imageHosting.desc')}>\n      <SettingSwitch />\n      {useImageRepo && (\n        <div className=\"flex flex-col gap-2\">\n          <label className=\"text-sm font-medium\">{t('settings.imageHosting.type')}</label>\n          <Select value={currentValue} onValueChange={handleValueChange}>\n            <SelectTrigger className=\"w-45\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"github\">Github</SelectItem>\n              <SelectItem value=\"smms\">SM.MS</SelectItem>\n              <SelectItem value=\"picgo\">PicGo</SelectItem>\n              <SelectItem value=\"s3\">S3</SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n      )}\n      {useImageRepo && currentValue === 'github' && (\n        <GithubImageHosting />\n      )}\n      {useImageRepo && currentValue === 'smms' && (\n        <SMMSImageHosting />\n      )}\n      {useImageRepo && currentValue === 'picgo' && (\n        <PicgoImageHosting />\n      )}\n      {useImageRepo && currentValue === 's3' && (\n        <S3ImageHosting />\n      )}\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/imageHosting/picgo.tsx",
    "content": "import { useTranslations } from 'next-intl';\nimport { Input } from \"@/components/ui/input\";\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useImageStore from \"@/stores/imageHosting\";\nimport { checkPicgoState, type PicgoImageHostingSetting } from \"@/lib/imageHosting/picgo\";\nimport { CheckCircle, LoaderCircle, XCircle } from \"lucide-react\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\n\nconst DEFAULT_URL = 'http://127.0.0.1:36677'\n\nexport default function PicgoImageHosting() {\n  useTranslations('settings.imageHosting');\n  useImageStore()\n\n  const [loading, setLoading] = useState(false)\n  const [picgoState, setPicgoState] = useState(false)\n  const [url, setUrl] = useState(DEFAULT_URL)\n\n  async function init() {\n    const store = await Store.load('store.json');\n    const picgoSetting = await store.get<PicgoImageHostingSetting>('picgo')\n    if (picgoSetting) {\n      setUrl(picgoSetting.url)\n    } else {\n      await store.set('picgo', { url: DEFAULT_URL })\n      await store.save()\n    }\n  }\n\n  async function handleCheckPicgoState() {\n    setLoading(true)\n    setPicgoState(false)\n    const state = await checkPicgoState()\n    setPicgoState(state)\n    setLoading(false)\n  }\n\n  async function handleSaveUrl(url: string) {\n    const store = await Store.load('store.json');\n    await store.set('picgo', { url })\n    await store.save()\n    setUrl(url)\n    handleCheckPicgoState()\n  }\n\n  useEffect(() => {\n    init()\n    handleCheckPicgoState()\n    window.addEventListener('visibilitychange', handleCheckPicgoState)\n    return () => {\n      window.removeEventListener('visibilitychange', handleCheckPicgoState)\n    }\n  }, [])\n\n  const getStatusIcon = () => {\n    if (loading) {\n      return <LoaderCircle className=\"size-4 animate-spin text-blue-500\" />;\n    }\n    if (picgoState) {\n      return <CheckCircle className=\"size-4 text-green-500\" />;\n    }\n    return <XCircle className=\"size-4 text-red-500\" />;\n  };\n\n  const getStatusText = () => {\n    if (loading) {\n      return '检测中';\n    }\n    if (picgoState) {\n      return '已连接';\n    }\n    return '未连接';\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>PicGo 图床</CardTitle>\n            <CardDescription>\n              使用 PicGo 客户端作为图片上传工具\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">连接状态</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n\n        {/* URL 配置 */}\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium\">PicGo Server</label>\n          <Input\n            type=\"text\"\n            value={url}\n            onChange={(e) => handleSaveUrl(e.target.value)}\n            placeholder=\"http://127.0.0.1:36677\"\n          />\n        </div>\n\n      </CardContent>\n    </Card>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageHosting/s3.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { useTranslations } from 'next-intl';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from 'lucide-react';\nimport useImageStore from '@/stores/imageHosting';\nimport { SyncStateEnum } from '@/lib/sync/github.types';\nimport { testS3Connection } from '@/lib/imageHosting/s3';\nimport { Store } from '@tauri-apps/plugin-store';\n\ninterface S3Config {\n  accessKeyId: string\n  secretAccessKey: string\n  region: string\n  bucket: string\n  endpoint?: string\n  customDomain?: string\n  pathPrefix?: string\n}\n\nexport function S3ImageHosting() {\n  const t = useTranslations();\n  const { setS3Config, s3State, setS3State } = useImageStore();\n  \n  const [config, setConfig] = useState<S3Config>({\n    accessKeyId: '',\n    secretAccessKey: '',\n    region: 'us-east-1',\n    bucket: '',\n    endpoint: '',\n    customDomain: '',\n    pathPrefix: ''\n  });\n  \n  const [showSecretKey, setShowSecretKey] = useState(false);\n\n  // 初始化配置\n  useEffect(() => {\n    const initConfig = async () => {\n      const store = await Store.load('store.json');\n      const savedConfig = await store.get<S3Config>('s3Config');\n      if (savedConfig) {\n        setConfig(savedConfig);\n        // 如果配置完整，自动进行连接检测\n        if (savedConfig.accessKeyId && savedConfig.secretAccessKey && savedConfig.region && savedConfig.bucket) {\n          setS3State(SyncStateEnum.checking);\n          try {\n            const isConnected = await testS3Connection(savedConfig);\n            if (isConnected) {\n              setS3State(SyncStateEnum.success);\n            } else {\n              setS3State(SyncStateEnum.fail);\n            }\n          } catch (error) {\n            setS3State(SyncStateEnum.fail);\n            console.error('S3 connection test failed:', error);\n          }\n        }\n      }\n    };\n    initConfig();\n  }, [setS3Config]);\n\n  // 自动保存和测试配置\n  const handleConfigChange = async (newConfig: S3Config) => {\n    setConfig(newConfig);\n    \n    // 自动保存配置\n    try {\n      await setS3Config(newConfig);\n    } catch (error) {\n      console.error('Failed to save S3 config:', error);\n    }\n    \n    // 如果必填字段都已填写，自动测试连接\n    if (newConfig.accessKeyId && newConfig.secretAccessKey && newConfig.region && newConfig.bucket) {\n      setS3State(SyncStateEnum.checking);\n\n      try {\n        const isConnected = await testS3Connection(newConfig);\n        if (isConnected) {\n          setS3State(SyncStateEnum.success);\n        } else {\n          setS3State(SyncStateEnum.fail);\n        }\n      } catch (error) {\n        setS3State(SyncStateEnum.fail);\n        console.error('S3 connection test failed:', error);\n      }\n    } else {\n      setS3State(SyncStateEnum.fail);\n    }\n  };\n\n  const getStatusIcon = () => {\n    switch (s3State) {\n      case SyncStateEnum.success:\n        return <CheckCircle className=\"size-4 text-green-500\" />;\n      case SyncStateEnum.checking:\n        return <Loader2 className=\"size-4 animate-spin text-blue-500\" />;\n      case SyncStateEnum.fail:\n      default:\n        return <XCircle className=\"size-4 text-red-500\" />;\n    }\n  };\n\n  const getStatusText = () => {\n    switch (s3State) {\n      case SyncStateEnum.success:\n        return t('settings.imageHosting.s3.connected');\n      case SyncStateEnum.checking:\n        return t('settings.imageHosting.s3.connecting');\n      case SyncStateEnum.fail:\n      default:\n        return t('settings.imageHosting.s3.disconnected');\n    }\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>{t('settings.imageHosting.s3.title')}</CardTitle>\n            <CardDescription>\n              {t('settings.imageHosting.s3.description')}\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">{t('settings.imageHosting.s3.status')}</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n        {/* 基本配置 */}\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"accessKeyId\">{t('settings.imageHosting.s3.accessKeyId')}</Label>\n            <Input\n              id=\"accessKeyId\"\n              type=\"text\"\n              value={config.accessKeyId}\n              onChange={(e) => handleConfigChange({ ...config, accessKeyId: e.target.value })}\n              placeholder={t('settings.imageHosting.s3.accessKeyIdPlaceholder')}\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"secretAccessKey\">{t('settings.imageHosting.s3.secretAccessKey')}</Label>\n            <div className=\"relative\">\n              <Input\n                id=\"secretAccessKey\"\n                type={showSecretKey ? \"text\" : \"password\"}\n                value={config.secretAccessKey}\n                onChange={(e) => handleConfigChange({ ...config, secretAccessKey: e.target.value })}\n                placeholder={t('settings.imageHosting.s3.secretAccessKeyPlaceholder')}\n                className=\"pr-10\"\n              />\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent\"\n                onClick={() => setShowSecretKey(!showSecretKey)}\n              >\n                {showSecretKey ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n              </Button>\n            </div>\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"region\">{t('settings.imageHosting.s3.region')}</Label>\n            <Input\n              id=\"region\"\n              type=\"text\"\n              value={config.region}\n              onChange={(e) => handleConfigChange({ ...config, region: e.target.value })}\n              placeholder=\"us-east-1\"\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"bucket\">{t('settings.imageHosting.s3.bucket')}</Label>\n            <Input\n              id=\"bucket\"\n              type=\"text\"\n              value={config.bucket}\n              onChange={(e) => handleConfigChange({ ...config, bucket: e.target.value })}\n              placeholder={t('settings.imageHosting.s3.bucketPlaceholder')}\n            />\n          </div>\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"endpoint\">{t('settings.imageHosting.s3.endpoint')}</Label>\n            <Input\n              id=\"endpoint\"\n              type=\"text\"\n              value={config.endpoint || ''}\n              onChange={(e) => handleConfigChange({ ...config, endpoint: e.target.value })}\n              placeholder=\"https://s3.amazonaws.com\"\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"customDomain\">{t('settings.imageHosting.s3.customDomain')}</Label>\n            <Input\n              id=\"customDomain\"\n              type=\"text\"\n              value={config.customDomain || ''}\n              onChange={(e) => handleConfigChange({ ...config, customDomain: e.target.value })}\n              placeholder=\"https://cdn.example.com\"\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"pathPrefix\">{t('settings.imageHosting.s3.pathPrefix')}</Label>\n            <Input\n              id=\"pathPrefix\"\n              type=\"text\"\n              value={config.pathPrefix || ''}\n              onChange={(e) => handleConfigChange({ ...config, pathPrefix: e.target.value })}\n              placeholder=\"images/\"\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              {t('settings.imageHosting.s3.pathPrefixDesc')}\n            </p>\n          </div>\n        </div>\n\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/app/core/setting/imageHosting/setting-switch.tsx",
    "content": "import useSettingStore from \"@/stores/setting\"\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item'\nimport { Switch } from \"@/components/ui/switch\"\nimport { useTranslations } from 'next-intl';\n\nexport function SettingSwitch() {\n  const t = useTranslations('settings.sync')\n  const {\n    useImageRepo,\n    setUseImageRepo,\n  } = useSettingStore()\n  return (\n    <Item variant=\"outline\">\n      <ItemContent>\n        <ItemTitle>{t('imageRepoSetting')}</ItemTitle>\n        <ItemDescription>{t('imageRepoSettingDesc')}</ItemDescription>\n      </ItemContent>\n      <ItemActions>\n        <Switch \n          checked={useImageRepo} \n          onCheckedChange={(checked) => setUseImageRepo(checked)} \n        />\n      </ItemActions>\n    </Item>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageHosting/smms.tsx",
    "content": "import { useTranslations } from 'next-intl';\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Eye, EyeOff, LoaderCircle, CheckCircle, XCircle } from \"lucide-react\";\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { type SMMSUserInfo, type SMMSImageHostingSetting } from \"@/lib/imageHosting/smms\";\nimport useImageStore from \"@/stores/imageHosting\";\nimport { getUserInfo } from \"@/lib/imageHosting/smms\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\n\nconst CREATE_TOKEN_URL = 'https://sm.ms/home/apitoken'\n\nexport default function SMMSImageHosting() {\n  const t = useTranslations('settings.imageHosting');\n  useImageStore()\n\n  const [loading, setLoading] = useState(false)\n  const [token, setToken] = useState('')\n  const [tokenVisible, setTokenVisible] = useState(false)\n  const [userInfo, setUserInfo] = useState<SMMSUserInfo | null>(null)\n\n  async function init() {\n    const store = await Store.load('store.json');\n    const imageHostings = await store.get<SMMSImageHostingSetting>('smms')\n    if (imageHostings) {\n      setToken(imageHostings.token)\n    }\n  }\n\n  // 设置 token\n  async function handleSetToken(token: string) {\n    setToken(token)\n    const store = await Store.load('store.json');\n    await store.set('smms', { token })\n    await store.save()\n  }\n\n  // 获取用户信息\n  async function handleSetUserInfo() {\n    setLoading(true)\n    setUserInfo(null)\n    const user = await getUserInfo()\n    if (user) {\n      setUserInfo(user)\n    }\n    setLoading(false)\n  }\n\n  useEffect(() => {\n    init()\n  }, [])\n\n  useEffect(() => {\n    handleSetUserInfo()\n  }, [token])\n\n  const getStatusIcon = () => {\n    if (loading) {\n      return <LoaderCircle className=\"size-4 animate-spin text-blue-500\" />;\n    }\n    if (token && userInfo) {\n      return <CheckCircle className=\"size-4 text-green-500\" />;\n    }\n    if (token && !userInfo) {\n      return <XCircle className=\"size-4 text-red-500\" />;\n    }\n    return <XCircle className=\"size-4 text-gray-500\" />;\n  };\n\n  const getStatusText = () => {\n    if (loading) {\n      return '检测中';\n    }\n    if (token && userInfo) {\n      return '已连接';\n    }\n    if (token && !userInfo) {\n      return '连接失败';\n    }\n    return '未配置';\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>SM.MS 图床</CardTitle>\n            <CardDescription>\n              使用 SM.MS 免费图片存储服务\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">连接状态</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n        {/* Token 配置 */}\n        <div className=\"space-y-2\">\n          <label className=\"text-sm font-medium\">API Token</label>\n          <p className=\"text-xs text-muted-foreground\">{t('smms.token.desc')}</p>\n          <div className=\"flex items-center gap-2\">\n            <Input\n              className=\"flex-1\"\n              type={tokenVisible ? 'text' : 'password'}\n              value={token}\n              onChange={(e) => handleSetToken(e.target.value)}\n              placeholder=\"输入 SM.MS API Token\"\n            />\n            <Button variant=\"outline\" size=\"icon\" onClick={() => setTokenVisible(!tokenVisible)}>\n              {tokenVisible ? <Eye /> : <EyeOff />}\n            </Button>\n          </div>\n          <OpenBroswer url={CREATE_TOKEN_URL} title={t('smms.token.createToken')} className=\"text-sm text-blue-500 hover:underline\" />\n        </div>\n\n        {/* 磁盘使用情况 */}\n        {token && (\n          <div className=\"space-y-2\">\n            <label className=\"text-sm font-medium\">磁盘使用情况</label>\n            <div className=\"p-3 border rounded-lg\">\n              <div className=\"flex items-center gap-2\">\n                {loading && <LoaderCircle className=\"animate-spin size-4\" />}\n                {!loading && userInfo && (\n                  <span className=\"text-sm\">{userInfo?.disk_usage} / {userInfo?.disk_limit}</span>\n                )}\n                {!loading && !userInfo && (\n                  <span className=\"text-sm text-red-500\">{t('smms.error')}</span>\n                )}\n              </div>\n            </div>\n          </div>\n        )}\n\n      </CardContent>\n    </Card>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageMethod/ocr.tsx",
    "content": "import { Input } from \"@/components/ui/input\";\nimport { FormItem } from \"../components/setting-base\";\nimport { useTranslations } from 'next-intl';\nimport { useEffect } from \"react\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport { SetDefault } from \"./setDefault\";\n\nexport function OcrSetting() {\n  const t = useTranslations('settings.imageMethod.ocr');\n  const { tesseractList, setTesseractList } = useSettingStore()\n\n  async function changeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    setTesseractList(e.target.value)\n    const store = await Store.load('store.json');\n    await store.set('tesseractList', e.target.value)\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const list = await store.get<string>('tesseractList')\n      if (list) {\n        setTesseractList(list)\n      } else {\n        setTesseractList('')\n      }\n    }\n    init()\n  }, [])\n\n  return (\n    <div className=\"space-y-8\">\n      <FormItem title={t('languagePacks')}>\n        <Input value={tesseractList} onChange={changeHandler} />\n      </FormItem>\n      <div>\n        <span>\n          <OpenBroswer title={t('checkModels')} url=\"https://tesseract-ocr.github.io/tessdoc/Data-Files#data-files-for-version-400-november-29-2016\" />\n          {t('modelInstruction')}\n        </span>\n      </div>\n      <SetDefault type=\"ocr\" />\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageMethod/page.tsx",
    "content": "'use client';\nimport { ImageIcon } from \"lucide-react\"\nimport { useTranslations } from 'next-intl';\nimport { SettingType } from '../components/setting-base';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { SquareCheckBig } from \"lucide-react\"\nimport { useState } from \"react\";\nimport { useEffect } from \"react\";\nimport { OcrSetting } from \"./ocr\";\nimport { VlmSetting } from \"./vlm\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { Eye } from 'lucide-react';\n\nexport default function ImageMethod() {\n  const t = useTranslations('settings.imageMethod');\n  const tabs = ['ocr', 'vlm']\n  const [tab, setTab] = useState<'ocr' | 'vlm'>('ocr')\n  const { primaryImageMethod, setPrimaryImageMethod, enableImageRecognition, setEnableImageRecognition } = useSettingStore()\n\n  async function init () {\n    const store = await Store.load('store.json')\n    const primaryImageMethod = await store.get<'ocr' | 'vlm'>('primaryImageMethod') || 'ocr'\n    setTab(primaryImageMethod)\n    setPrimaryImageMethod(primaryImageMethod)\n  }\n\n  useEffect(() => {\n    init()\n  }, [])\n  \n  return (\n    <SettingType id=\"sync\" icon={<ImageIcon />} title={t('title')} desc={t('desc')}>\n      <Item variant=\"outline\" className=\"mb-6\">\n        <ItemMedia variant=\"icon\"><Eye className=\"size-4\" /></ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('enable.title')}</ItemTitle>\n          <ItemDescription>{t('enable.desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <Switch\n            checked={enableImageRecognition}\n            onCheckedChange={setEnableImageRecognition}\n          />\n        </ItemActions>\n      </Item>\n\n      <Tabs value={tab} onValueChange={(value) => setTab(value as 'ocr' | 'vlm')}>\n        <TabsList className=\"grid grid-cols-2 w-full mb-8\">\n          {\n            tabs.map((item) => (\n              <TabsTrigger value={item} key={item} className=\"flex items-center gap-2\">\n                {item.toUpperCase()}\n                {primaryImageMethod === item && <SquareCheckBig className=\"size-4\" />}\n              </TabsTrigger>\n            ))\n          }\n        </TabsList>\n        <TabsContent value=\"ocr\">\n          <OcrSetting />\n        </TabsContent>\n        <TabsContent value=\"vlm\">\n          <VlmSetting />\n        </TabsContent>\n      </Tabs>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/imageMethod/setDefault.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { useTranslations } from 'next-intl';\nimport { useEffect } from \"react\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\nexport function SetDefault({type}: {type: 'ocr' | 'vlm'}) {\n  const t = useTranslations('settings.imageMethod');\n  const { primaryImageMethod, setPrimaryImageMethod } = useSettingStore()\n\n  async function init() {\n    const store = await Store.load('store.json');\n    const method = await store.get<'ocr' | 'vlm'>('primaryImageMethod') || 'ocr'\n    setPrimaryImageMethod(method)\n  }\n\n  async function handleSetPrimary() {\n    setPrimaryImageMethod(type)\n  }\n\n  useEffect(() => {\n    init()\n  }, [])\n\n  return (\n    <div>\n      {primaryImageMethod === type ? (\n        <Button disabled variant=\"outline\">\n          {t('isPrimary', { type: type.toUpperCase() })}\n        </Button>\n      ) : (\n        <Button \n          variant=\"outline\" \n          onClick={handleSetPrimary}\n        >\n          {t('setPrimary')}\n        </Button>\n      )}\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/imageMethod/vlm.tsx",
    "content": "import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { ModelSelect } from \"../components/model-select\";\nimport { Bot } from \"lucide-react\";\nimport { useTranslations } from 'next-intl';\nimport { SetDefault } from \"./setDefault\";\n\nexport function VlmSetting() {\n  const t = useTranslations('settings.imageMethod.vlm')\n  return (\n    <div className='space-y-4'>\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\"><Bot className=\"size-4\" /></ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('title')}</ItemTitle>\n          <ItemDescription>{t('desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions className='max-md:w-full'>\n          <ModelSelect modelKey={'imageMethod'} />\n        </ItemActions>\n      </Item>\n      <SetDefault type=\"vlm\" />\n    </div> \n  )\n}"
  },
  {
    "path": "src/app/core/setting/layout.tsx",
    "content": "'use client'\n\nimport { SettingTab } from \"./components/setting-tab\"\n\nexport default function SettingLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <div id=\"setting-page\" className=\"flex h-full\">\n      <SettingTab />\n      <div className=\"flex-1 p-8 overflow-y-auto h-full\">\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/connection-test.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Loader2, CheckCircle2, XCircle } from 'lucide-react'\nimport { mcpServerManager } from '@/lib/mcp/server-manager'\nimport type { MCPServerConfig } from '@/lib/mcp/types'\n\ninterface ConnectionTestProps {\n  server: MCPServerConfig\n}\n\nexport function ConnectionTest({ server }: ConnectionTestProps) {\n  const t = useTranslations('settings.mcp')\n  const [testing, setTesting] = useState(false)\n  const [result, setResult] = useState<'success' | 'error' | null>(null)\n  \n  const handleTest = async () => {\n    setTesting(true)\n    setResult(null)\n    \n    try {\n      const success = await mcpServerManager.testConnection(server)\n      setResult(success ? 'success' : 'error')\n    } catch {\n      setResult('error')\n    } finally {\n      setTesting(false)\n    }\n  }\n  \n  return (\n    <div className=\"flex items-center gap-2\">\n      <Button\n        size=\"sm\"\n        variant=\"outline\"\n        onClick={handleTest}\n        disabled={testing}\n      >\n        {testing && <Loader2 className=\"mr-2 size-3 animate-spin\" />}\n        {t('test')}\n      </Button>\n      \n      {result === 'success' && (\n        <CheckCircle2 className=\"size-4 text-green-500\" />\n      )}\n      \n      {result === 'error' && (\n        <XCircle className=\"size-4 text-red-500\" />\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/json-import-dialog.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n  DialogDescription,\n} from '@/components/ui/dialog'\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerDescription,\n} from '@/components/ui/drawer'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { useMcpStore } from '@/stores/mcp'\nimport type { MCPServerConfig } from '@/lib/mcp/types'\nimport { useToast } from '@/hooks/use-toast'\nimport { mcpServerManager } from '@/lib/mcp/server-manager'\nimport { AlertCircle } from 'lucide-react'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\n\ninterface JsonImportDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function JsonImportDialog({ open, onOpenChange }: JsonImportDialogProps) {\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n  const t = useTranslations('settings.mcp')\n  const { toast } = useToast()\n  const { addServer, servers } = useMcpStore()\n\n  const [jsonText, setJsonText] = useState('')\n  const [error, setError] = useState('')\n\n  // 将 mcpServers 格式转换为标准配置数组\n  const convertMcpServersFormat = (parsed: any): MCPServerConfig[] => {\n    const configs: MCPServerConfig[] = []\n\n    // 检查是否是 mcpServers 格式: { \"mcpServers\": { \"serverName\": {...} } }\n    if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {\n      for (const [name, serverConfig] of Object.entries(parsed.mcpServers)) {\n        const config = serverConfig as any\n\n        // 检查是否是 stdio 类型 (有 command 字段)\n        if (config.command) {\n          configs.push({\n            id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n            name,\n            type: 'stdio',\n            enabled: true,\n            createdAt: Date.now(),\n            command: config.command,\n            args: config.args,\n            env: config.env,\n          })\n        }\n        // 检查是否是 http 类型 (有 url 字段)\n        else if (config.url) {\n          configs.push({\n            id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n            name,\n            type: 'http',\n            enabled: true,\n            createdAt: Date.now(),\n            url: config.url,\n            headers: config.headers,\n          })\n        }\n      }\n      return configs\n    }\n\n    // 检查是否是简化的 mcpServers 格式: { \"serverName\": {...} }\n    // 如果没有 mcpServers 字段，但第一层是对象且包含 command 或 url\n    if (!Array.isArray(parsed) && typeof parsed === 'object') {\n      let hasMcpFormat = false\n      for (const [, value] of Object.entries(parsed)) {\n        const config = value as any\n        if (config && (config.command || config.url)) {\n          hasMcpFormat = true\n          break\n        }\n      }\n\n      if (hasMcpFormat) {\n        for (const [name, serverConfig] of Object.entries(parsed)) {\n          const config = serverConfig as any\n          if (config.command) {\n            configs.push({\n              id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n              name,\n              type: 'stdio',\n              enabled: true,\n              createdAt: Date.now(),\n              command: config.command,\n              args: config.args,\n              env: config.env,\n            })\n          } else if (config.url) {\n            configs.push({\n              id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n              name,\n              type: 'http',\n              enabled: true,\n              createdAt: Date.now(),\n              url: config.url,\n              headers: config.headers,\n            })\n          }\n        }\n        return configs\n      }\n    }\n\n    // 支持标准数组格式\n    const standardConfigs = Array.isArray(parsed) ? parsed : [parsed]\n    for (const config of standardConfigs) {\n      configs.push({\n        id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n        name: config.name,\n        type: config.type || 'stdio',\n        enabled: config.enabled ?? true,\n        createdAt: Date.now(),\n        command: config.command,\n        args: config.args,\n        env: config.env,\n        url: config.url,\n        headers: config.headers,\n      })\n    }\n\n    return configs\n  }\n\n  const handleImport = async () => {\n    setError('')\n\n    if (!jsonText.trim()) {\n      setError(t('jsonRequired'))\n      return\n    }\n\n    try {\n      const parsed = JSON.parse(jsonText)\n\n      // 转换为标准配置数组\n      const configs = convertMcpServersFormat(parsed)\n\n      if (configs.length === 0) {\n        setError(t('jsonEmpty'))\n        return\n      }\n\n      let successCount = 0\n      let skippedCount = 0\n      const addedConfigs: MCPServerConfig[] = []\n\n      for (const config of configs) {\n        // 验证配置结构\n        if (!config.name || !config.type) {\n          setError(t('jsonInvalidFormat'))\n          return\n        }\n\n        if (config.type !== 'stdio' && config.type !== 'http') {\n          setError(t('jsonInvalidType'))\n          return\n        }\n\n        if (config.type === 'stdio' && !config.command) {\n          setError(t('jsonMissingCommand'))\n          return\n        }\n\n        if (config.type === 'http' && !config.url) {\n          setError(t('jsonMissingUrl'))\n          return\n        }\n\n        // 检查是否已存在同名服务器\n        const exists = servers.some(s => s.name === config.name)\n        if (exists) {\n          skippedCount++\n          continue\n        }\n\n        await addServer(config)\n        addedConfigs.push(config)\n        successCount++\n      }\n\n      onOpenChange(false)\n      setJsonText('')\n\n      if (successCount > 0) {\n        toast({\n          description: t('jsonImportSuccess', { count: successCount }),\n        })\n\n        // 自动连接已启用的新服务器\n        for (const config of addedConfigs) {\n          if (config.enabled) {\n            try {\n              await mcpServerManager.connectServer(config)\n            } catch (error) {\n              console.error(`Failed to auto-connect server ${config.name}:`, error)\n            }\n          }\n        }\n      }\n\n      if (skippedCount > 0) {\n        setTimeout(() => {\n          toast({\n            description: t('jsonImportSkipped', { count: skippedCount }),\n          })\n        }, 1000)\n      }\n\n      if (successCount === 0 && skippedCount === 0) {\n        toast({\n          description: t('jsonImportNoServers'),\n          variant: 'destructive',\n        })\n      }\n    } catch (e) {\n      setError(t('jsonInvalidJson') + ': ' + (e as Error).message)\n    }\n  }\n\n  const handleCancel = () => {\n    setJsonText('')\n    setError('')\n    onOpenChange(false)\n  }\n\n  const handleOpenChange = (newOpen: boolean) => {\n    if (!newOpen) {\n      setJsonText('')\n      setError('')\n    }\n    onOpenChange(newOpen)\n  }\n\n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={open} onOpenChange={handleOpenChange}>\n          <DrawerContent className=\"max-h-[85vh]\">\n            <DrawerHeader>\n              <DrawerTitle>{t('jsonImportTitle')}</DrawerTitle>\n              <DrawerDescription>{t('jsonImportDesc')}</DrawerDescription>\n            </DrawerHeader>\n\n            <div className=\"space-y-4 px-4 overflow-y-auto\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"json-input\">{t('jsonInput')}</Label>\n                <Textarea\n                  id=\"json-input\"\n                  value={jsonText}\n                  onChange={(e) => {\n                    setJsonText(e.target.value)\n                    setError('')\n                  }}\n                  placeholder={`{\n  \"mcpServers\": {\n    \"fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}`}\n                  rows={12}\n                  className=\"font-mono text-sm\"\n                />\n                <p className=\"text-xs text-muted-foreground\">{t('jsonInputHelp')}</p>\n              </div>\n\n              {error && (\n                <div className=\"flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive\">\n                  <AlertCircle className=\"size-4 mt-0.5 flex-shrink-0\" />\n                  <p className=\"text-sm\">{error}</p>\n                </div>\n              )}\n            </div>\n\n            <DrawerFooter>\n              <Button variant=\"outline\" onClick={handleCancel}>\n                {t('cancel')}\n              </Button>\n              <Button onClick={handleImport}>{t('import')}</Button>\n            </DrawerFooter>\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={open} onOpenChange={handleOpenChange}>\n          <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-y-auto\">\n            <DialogHeader>\n              <DialogTitle>{t('jsonImportTitle')}</DialogTitle>\n              <DialogDescription>{t('jsonImportDesc')}</DialogDescription>\n            </DialogHeader>\n\n            <div className=\"space-y-4 py-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"json-input\">{t('jsonInput')}</Label>\n                <Textarea\n                  id=\"json-input\"\n                  value={jsonText}\n                  onChange={(e) => {\n                    setJsonText(e.target.value)\n                    setError('')\n                  }}\n                  placeholder={`{\n  \"mcpServers\": {\n    \"fetch\": {\n      \"command\": \"uvx\",\n      \"args\": [\"mcp-server-fetch\"]\n    }\n  }\n}`}\n                  rows={12}\n                  className=\"font-mono text-sm\"\n                />\n                <p className=\"text-xs text-muted-foreground\">{t('jsonInputHelp')}</p>\n              </div>\n\n              {error && (\n                <div className=\"flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive\">\n                  <AlertCircle className=\"size-4 mt-0.5 flex-shrink-0\" />\n                  <p className=\"text-sm\">{error}</p>\n                </div>\n              )}\n            </div>\n\n            <DialogFooter>\n              <Button variant=\"outline\" onClick={handleCancel}>\n                {t('cancel')}\n              </Button>\n              <Button onClick={handleImport}>{t('import')}</Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/page.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Puzzle } from 'lucide-react'\nimport { SettingType } from '../components/setting-base'\nimport { ServerList } from './server-list'\nimport { RuntimeEnvironmentCard } from './runtime-environment-card'\nimport { useMcpStore } from '@/stores/mcp'\nimport { isMobileDevice } from '@/lib/check'\n\nexport default function McpSettingPage() {\n  const t = useTranslations('settings.mcp')\n  const { initMcpData } = useMcpStore()\n  const isMobile = isMobileDevice()\n  \n  useEffect(() => {\n    initMcpData()\n  }, [initMcpData])\n  \n  return (\n    <SettingType id=\"mcp\" title={t('title')} desc={t('desc')} icon={<Puzzle />}>\n      <div className=\"space-y-4\">\n        {!isMobile && <RuntimeEnvironmentCard />}\n        <ServerList />\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/runtime-environment-card.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, Loader2, TerminalSquare } from 'lucide-react'\nimport { listen } from '@tauri-apps/api/event'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { Card } from '@/components/ui/card'\nimport { useToast } from '@/hooks/use-toast'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport {\n  AlertDialog,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {\n  cancelMcpRuntimeInstall,\n  inspectMcpRuntime,\n  installMcpRuntime,\n  type MCPInstallRecipe,\n  type MCPInstallProgressEvent,\n  type MCPInstallProgressStage,\n  type MCPRuntimeInspection,\n} from '@/lib/mcp/runtime-assistant'\n\ntype RuntimeDefinition = {\n  key: string\n  label: string\n  command: string\n}\n\nconst RUNTIMES: RuntimeDefinition[] = [\n  { key: 'npx', label: 'Node.js / npx', command: 'npx' },\n  { key: 'uvx', label: 'uv / uvx', command: 'uvx' },\n  { key: 'bunx', label: 'Bun / bunx', command: 'bunx' },\n  { key: 'python3', label: 'Python 3', command: 'python3' },\n]\n\nexport function RuntimeEnvironmentCard() {\n  const t = useTranslations('settings.mcp')\n  const { toast } = useToast()\n  const [inspections, setInspections] = useState<Record<string, MCPRuntimeInspection>>({})\n  const [checkingAll, setCheckingAll] = useState(false)\n  const [installingRecipeId, setInstallingRecipeId] = useState<string | null>(null)\n  const [installRecipe, setInstallRecipe] = useState<MCPInstallRecipe | null>(null)\n  const [installDialogOpen, setInstallDialogOpen] = useState(false)\n  const [isOpen, setIsOpen] = useState(false)\n  const [installStage, setInstallStage] = useState<MCPInstallProgressStage>('preparing')\n  const [installLogs, setInstallLogs] = useState<string[]>([])\n  const activeRecipeIdRef = useRef<string | null>(null)\n\n  const inspectionEntries = useMemo(\n    () => RUNTIMES.map((runtime) => ({ runtime, inspection: inspections[runtime.key] })),\n    [inspections],\n  )\n\n  const hasAnyInspection = useMemo(() => inspectionEntries.some((entry) => Boolean(entry.inspection)), [inspectionEntries])\n  const installedCount = useMemo(\n    () => inspectionEntries.filter((entry) => entry.inspection?.checks.some((check) => check.installed)).length,\n    [inspectionEntries],\n  )\n\n  useEffect(() => {\n    let unlisten: (() => void) | undefined\n\n    async function bindListener() {\n      unlisten = await listen<MCPInstallProgressEvent>('mcp-runtime-install', (event) => {\n        const payload = event.payload\n        if (!payload || payload.recipeId !== activeRecipeIdRef.current) {\n          return\n        }\n\n        setInstallStage(payload.stage)\n        if (payload.line) {\n          const prefix = payload.stream ? `[${payload.stream}] ` : ''\n          setInstallLogs((prev) => [...prev, `${prefix}${payload.line}`])\n        }\n      })\n    }\n\n    bindListener()\n\n    return () => {\n      if (unlisten) {\n        unlisten()\n      }\n    }\n  }, [])\n\n  const installStageLabel = useMemo(() => {\n    switch (installStage) {\n      case 'preparing':\n        return t('runtimeInstallPreparing')\n      case 'running':\n        return t('runtimeInstallRunning')\n      case 'completed':\n        return t('runtimeInstallCompleted')\n      case 'cancelled':\n        return t('runtimeInstallCancelled')\n      case 'failed':\n        return t('runtimeInstallFailedState')\n      default:\n        return t('runtimeInstallPreparing')\n    }\n  }, [installStage, t])\n\n  const runInspection = async (runtime: RuntimeDefinition) => {\n    const inspection = await inspectMcpRuntime(runtime.command)\n    setInspections((prev) => ({ ...prev, [runtime.key]: inspection }))\n    return inspection\n  }\n\n  const handleCheckAll = async () => {\n    setCheckingAll(true)\n    try {\n      await Promise.all(RUNTIMES.map((runtime) => runInspection(runtime)))\n    } catch (error) {\n      toast({\n        description: `${t('runtimeCheckFailed')}: ${error}`,\n        variant: 'destructive',\n      })\n    } finally {\n      setCheckingAll(false)\n    }\n  }\n\n  const handleInstallClick = (recipe: MCPInstallRecipe) => {\n    setInstallRecipe(recipe)\n    activeRecipeIdRef.current = recipe.id\n    setInstallStage('preparing')\n    setInstallLogs([])\n    setInstallDialogOpen(true)\n  }\n\n  const handleConfirmInstall = async () => {\n    if (!installRecipe) {\n      return\n    }\n\n    setInstallingRecipeId(installRecipe.id)\n    setInstallStage('preparing')\n    setInstallLogs([])\n    try {\n      const result = await installMcpRuntime(installRecipe.id)\n      setInstallStage(result.success ? 'completed' : 'failed')\n      toast({\n        description: result.success ? t('runtimeInstallSuccess') : t('runtimeInstallFailed'),\n        variant: result.success ? 'default' : 'destructive',\n      })\n\n      const matchedRuntime = RUNTIMES.find((runtime) => {\n        const inspection = inspections[runtime.key]\n        return inspection?.installRecipe?.id === installRecipe.id\n      })\n      if (matchedRuntime) {\n        await runInspection(matchedRuntime)\n      }\n    } catch (error) {\n      setInstallStage('failed')\n      setInstallLogs((prev) => [...prev, String(error)])\n      toast({\n        description: `${t('runtimeInstallFailed')}: ${error}`,\n        variant: 'destructive',\n      })\n    } finally {\n      setInstallingRecipeId(null)\n    }\n  }\n\n  const handleInstallDialogOpenChange = (open: boolean) => {\n    if (installingRecipeId) {\n      return\n    }\n\n    setInstallDialogOpen(open)\n    if (!open) {\n      activeRecipeIdRef.current = null\n    }\n  }\n\n  const handleCancelInstall = async () => {\n    if (!installRecipe) {\n      return\n    }\n\n    try {\n      const result = await cancelMcpRuntimeInstall(installRecipe.id)\n      if (result.cancelled) {\n        setInstallStage('cancelled')\n        setInstallLogs((prev) => [...prev, t('runtimeInstallCancelledByUser')])\n      }\n    } catch (error) {\n      setInstallLogs((prev) => [...prev, `${t('runtimeInstallCancelFailed')}: ${error}`])\n      toast({\n        description: `${t('runtimeInstallCancelFailed')}: ${error}`,\n        variant: 'destructive',\n      })\n    } finally {\n      setInstallingRecipeId(null)\n    }\n  }\n\n  return (\n    <>\n      <Card className=\"p-4 space-y-4\">\n        <div className=\"flex flex-col gap-3 md:flex-row md:items-start md:justify-between\">\n          <div className=\"space-y-1\">\n            <div className=\"flex items-center gap-2\">\n              <TerminalSquare className=\"size-4 text-muted-foreground\" />\n              <p className=\"font-medium\">{t('runtimeEnvironment')}</p>\n              {hasAnyInspection && (\n                <Badge variant=\"outline\">\n                  {t('runtimeInstalledSummary', { installed: installedCount, total: RUNTIMES.length })}\n                </Badge>\n              )}\n            </div>\n            <p className=\"text-sm text-muted-foreground\">{t('runtimeEnvironmentDesc')}</p>\n            <p className=\"text-xs text-muted-foreground\">{t('runtimeCurrentUserScope')}</p>\n          </div>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            onClick={handleCheckAll}\n            disabled={checkingAll}\n          >\n            {checkingAll && <Loader2 className=\"mr-2 size-4 animate-spin\" />}\n            {hasAnyInspection ? t('recheckEnvironment') : t('checkEnvironment')}\n          </Button>\n        </div>\n\n        <Collapsible open={isOpen} onOpenChange={setIsOpen}>\n          <CollapsibleTrigger asChild>\n            <Button type=\"button\" variant=\"ghost\" className=\"w-full justify-between px-2\">\n              <span>{isOpen ? t('hideRuntimeDetails') : t('showRuntimeDetails')}</span>\n              {isOpen ? <ChevronUp className=\"size-4\" /> : <ChevronDown className=\"size-4\" />}\n            </Button>\n          </CollapsibleTrigger>\n\n          <CollapsibleContent className=\"pt-2\">\n            <div className=\"grid gap-3\">\n              {inspectionEntries.map(({ runtime, inspection }) => {\n                const isInstalled = inspection?.checks.some((check) => check.installed) ?? false\n\n                return (\n                  <div key={runtime.key} className=\"rounded-lg border p-4 space-y-3\">\n                    <div className=\"space-y-1\">\n                      <div className=\"flex items-center gap-2\">\n                        <p className=\"text-sm font-medium\">{runtime.label}</p>\n                        <Badge variant=\"outline\">{runtime.command}</Badge>\n                        {inspection && (\n                          <Badge\n                            variant=\"outline\"\n                            className={isInstalled ? 'text-green-600 border-green-200' : 'text-amber-600 border-amber-200'}\n                          >\n                            {isInstalled ? t('runtimeInstalled') : t('runtimeMissing')}\n                          </Badge>\n                        )}\n                      </div>\n                      {inspection ? (\n                        <p className=\"text-xs text-muted-foreground\">\n                          {t('detectedLauncher')}: {inspection.launcher}\n                        </p>\n                      ) : (\n                        <p className=\"text-xs text-muted-foreground\">{t('runtimeNotChecked')}</p>\n                      )}\n                    </div>\n\n                    {inspection && (\n                      <div className=\"space-y-3\">\n                        {inspection.checks.map((check) => (\n                          <div key={check.command} className=\"rounded-md border bg-muted/30 p-3 space-y-2\">\n                            <div className=\"flex items-start justify-between gap-3\">\n                              <div className=\"space-y-1\">\n                                <p className=\"text-sm font-medium\">{check.command}</p>\n                                {check.resolvedPath && (\n                                  <p className=\"break-all text-xs text-muted-foreground\">{check.resolvedPath}</p>\n                                )}\n                              </div>\n                              <Badge\n                                variant=\"outline\"\n                                className={check.installed ? 'text-green-600 border-green-200' : 'text-amber-600 border-amber-200'}\n                              >\n                                {check.installed ? t('runtimeInstalled') : t('runtimeMissing')}\n                              </Badge>\n                            </div>\n                            {check.version && (\n                              <p className=\"text-xs text-muted-foreground\">\n                                {t('runtimeVersion')}: {check.version}\n                              </p>\n                            )}\n                          </div>\n                        ))}\n\n                        {!isInstalled && inspection.installRecipe && (\n                          <div className=\"rounded-md border border-dashed p-3 space-y-3\">\n                            <div className=\"flex items-start gap-2\">\n                              {inspection.installRecipe.manualOnly ? (\n                                <AlertTriangle className=\"size-4 mt-0.5 text-amber-500\" />\n                              ) : (\n                                <CheckCircle2 className=\"size-4 mt-0.5 text-blue-500\" />\n                              )}\n                              <div className=\"space-y-1\">\n                                <p className=\"text-sm font-medium\">{inspection.installRecipe.title}</p>\n                                <p className=\"text-xs text-muted-foreground\">{t('runtimeCurrentUserScope')}</p>\n                              </div>\n                            </div>\n                            <pre className=\"whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs\">\n                              {inspection.installRecipe.commandPreview}\n                            </pre>\n                            {inspection.installRecipe.postInstallHint && (\n                              <p className=\"text-xs text-muted-foreground\">\n                                {inspection.installRecipe.postInstallHint}\n                              </p>\n                            )}\n                            {inspection.installRecipe.manualOnly ? (\n                              <p className=\"text-xs text-muted-foreground\">{t('runtimeManualOnly')}</p>\n                            ) : (\n                              <Button\n                                type=\"button\"\n                                variant=\"outline\"\n                                onClick={() => handleInstallClick(inspection.installRecipe!)}\n                                disabled={installingRecipeId === inspection.installRecipe.id}\n                              >\n                                {installingRecipeId === inspection.installRecipe.id && (\n                                  <Loader2 className=\"mr-2 size-4 animate-spin\" />\n                                )}\n                                {t('installRuntime')}\n                              </Button>\n                            )}\n                          </div>\n                        )}\n                      </div>\n                    )}\n                  </div>\n                )\n              })}\n            </div>\n          </CollapsibleContent>\n        </Collapsible>\n      </Card>\n\n      <AlertDialog open={installDialogOpen} onOpenChange={handleInstallDialogOpenChange}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('runtimeInstallTitle')}</AlertDialogTitle>\n            <AlertDialogDescription>{t('runtimeInstallDesc')}</AlertDialogDescription>\n          </AlertDialogHeader>\n          {installRecipe && (\n            <div className=\"space-y-3\">\n              <div className=\"flex items-center gap-2\">\n                <Badge\n                  variant=\"outline\"\n                  className={\n                    installStage === 'completed'\n                      ? 'text-green-600 border-green-200'\n                      : installStage === 'failed'\n                        ? 'text-red-600 border-red-200'\n                        : 'text-blue-600 border-blue-200'\n                  }\n                >\n                  {installStageLabel}\n                </Badge>\n                {installingRecipeId === installRecipe.id && <Loader2 className=\"size-4 animate-spin text-muted-foreground\" />}\n              </div>\n              <pre className=\"whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs\">\n                {installRecipe.commandPreview}\n              </pre>\n              {installRecipe.postInstallHint && (\n                <p className=\"text-xs text-muted-foreground\">\n                  {installRecipe.postInstallHint}\n                </p>\n              )}\n              <div className=\"rounded-md border bg-muted/30 p-3\">\n                <p className=\"mb-2 text-xs font-medium text-muted-foreground\">{t('runtimeInstallLogs')}</p>\n                <div className=\"max-h-56 overflow-y-auto rounded bg-background p-3 font-mono text-xs\">\n                  {installLogs.length > 0 ? (\n                    <pre className=\"whitespace-pre-wrap break-all\">{installLogs.join('\\n')}</pre>\n                  ) : (\n                    <p className=\"text-muted-foreground\">{t('runtimeInstallWaitingLogs')}</p>\n                  )}\n                </div>\n              </div>\n            </div>\n          )}\n          <AlertDialogFooter>\n            {installingRecipeId === installRecipe?.id ? (\n              <Button variant=\"outline\" onClick={handleCancelInstall}>\n                {t('runtimeInstallCancel')}\n              </Button>\n            ) : (\n              <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n            )}\n            {installingRecipeId === installRecipe?.id ? (\n              <Button disabled>\n                <Loader2 className=\"mr-2 size-4 animate-spin\" />\n                {installStageLabel}\n              </Button>\n            ) : installStage === 'completed' || installStage === 'failed' ? (\n              <Button onClick={() => handleInstallDialogOpenChange(false)}>\n                {t('runtimeInstallClose')}\n              </Button>\n            ) : (\n              <Button onClick={handleConfirmInstall}>\n                {t('installRuntime')}\n              </Button>\n            )}\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/server-config-dialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog'\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n} from '@/components/ui/drawer'\nimport { Button } from '@/components/ui/button'\nimport { Card } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Switch } from '@/components/ui/switch'\nimport { useMcpStore } from '@/stores/mcp'\nimport type { MCPServerConfig, MCPServerType } from '@/lib/mcp/types'\nimport { Loader2, AlertTriangle } from 'lucide-react'\nimport { mcpServerManager } from '@/lib/mcp/server-manager'\nimport { useToast } from '@/hooks/use-toast'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\n\ninterface ServerConfigDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  editingServer?: MCPServerConfig | null\n}\n\nexport function ServerConfigDialog({\n  open,\n  onOpenChange,\n  editingServer,\n}: ServerConfigDialogProps) {\n  const isActualMobile = checkIsMobileDevice()\n  const isMobile = useIsMobile() || isActualMobile\n  const t = useTranslations('settings.mcp')\n  const { toast } = useToast()\n  const { addServer, updateServer, selectedServerIds, setSelectedServers } = useMcpStore()\n  \n  const [name, setName] = useState('')\n  const [type, setType] = useState<MCPServerType>('stdio')\n  const [command, setCommand] = useState('')\n  const [args, setArgs] = useState('')\n  const [env, setEnv] = useState('')\n  const [url, setUrl] = useState('')\n  const [headers, setHeaders] = useState('')\n  const [enabled, setEnabled] = useState(true)\n  const [testing, setTesting] = useState(false)\n  \n  useEffect(() => {\n    if (open) {\n      if (editingServer) {\n        setName(editingServer.name)\n        setType(editingServer.type)\n        \n        // 对于 stdio 类型，如果 command 和 args 都存在，合并显示在 command 字段\n        if (editingServer.type === 'stdio' && editingServer.command && editingServer.args && editingServer.args.length > 0) {\n          const fullCommand = `${editingServer.command} ${editingServer.args.join(' ')}`\n          setCommand(fullCommand)\n          setArgs('')\n        } else {\n          setCommand(editingServer.command || '')\n          setArgs((editingServer.args || []).join(' '))\n        }\n        \n        setEnv(JSON.stringify(editingServer.env || {}, null, 2))\n        setUrl(editingServer.url || '')\n        setHeaders(JSON.stringify(editingServer.headers || {}, null, 2))\n        setEnabled(editingServer.enabled ?? true)\n      } else {\n        resetForm()\n      }\n    }\n  }, [editingServer, open])\n  \n  const resetForm = () => {\n    setName('')\n    setType(isActualMobile ? 'http' : 'stdio')\n    setCommand('')\n    setArgs('')\n    setEnv('')\n    setUrl('')\n    setHeaders('')\n    setEnabled(true)\n  }\n\n  const isUnsupportedMobileStdio = isActualMobile && type === 'stdio'\n  \n  const handleTestConnection = async () => {\n    if (isUnsupportedMobileStdio) {\n      toast({ description: t('mobileHttpOnlyDesc'), variant: 'destructive' })\n      return\n    }\n\n    setTesting(true)\n    try {\n      // 使用临时 ID 进行测试\n      const config = buildConfig(true)\n      const success = await mcpServerManager.testConnection(config)\n      \n      if (success) {\n        toast({ description: t('testSuccess') })\n      } else {\n        toast({ description: t('testFailed'), variant: 'destructive' })\n      }\n    } catch (error) {\n      toast({ description: t('testFailed') + ': ' + error, variant: 'destructive' })\n    } finally {\n      setTesting(false)\n    }\n  }\n  \n  const buildConfig = (isTest: boolean = false): MCPServerConfig => {\n    const config: MCPServerConfig = {\n      // 测试时使用临时 ID，避免与已存在的服务器冲突\n      id: isTest ? `mcp-test-${Date.now()}` : (editingServer?.id || `mcp-${Date.now()}`),\n      name,\n      type,\n      enabled,\n      createdAt: editingServer?.createdAt || Date.now(),\n    }\n    \n    if (type === 'stdio') {\n      // 智能解析命令：如果 command 包含空格且 args 为空，自动分割\n      const commandParts = command.trim().split(/\\s+/)\n      if (commandParts.length > 1 && !args.trim()) {\n        // 第一个词是命令，其余是参数\n        config.command = commandParts[0]\n        config.args = commandParts.slice(1)\n      } else {\n        // 使用原有逻辑\n        config.command = command.trim()\n        config.args = args.split(' ').filter(Boolean)\n      }\n      \n      try {\n        config.env = env ? JSON.parse(env) : {}\n      } catch {\n        config.env = {}\n      }\n    } else {\n      config.url = url\n      try {\n        config.headers = headers ? JSON.parse(headers) : {}\n      } catch {\n        config.headers = {}\n      }\n    }\n    \n    return config\n  }\n  \n  const handleSave = async () => {\n    if (!name.trim()) {\n      toast({ description: t('nameRequired'), variant: 'destructive' })\n      return\n    }\n\n    if (isUnsupportedMobileStdio) {\n      toast({ description: t('mobileHttpOnlyDesc'), variant: 'destructive' })\n      return\n    }\n    \n    if (type === 'stdio' && !command.trim()) {\n      toast({ description: t('commandRequired'), variant: 'destructive' })\n      return\n    }\n    \n    if (type === 'http' && !url.trim()) {\n      toast({ description: t('urlRequired'), variant: 'destructive' })\n      return\n    }\n    \n    const config = buildConfig()\n    \n    if (editingServer) {\n      const wasEnabled = editingServer.enabled ?? true\n\n      if (wasEnabled && !config.enabled) {\n        await mcpServerManager.disconnectServer(editingServer.id)\n        if (selectedServerIds.includes(editingServer.id)) {\n          await setSelectedServers(selectedServerIds.filter(id => id !== editingServer.id))\n        }\n      }\n\n      await updateServer(editingServer.id, config)\n      toast({ description: t('serverUpdated') })\n\n      if (config.enabled) {\n        try {\n          await mcpServerManager.reconnectServer(config)\n        } catch (error) {\n          console.error('Failed to reconnect after save:', error)\n        }\n      }\n    } else {\n      await addServer(config)\n      toast({ description: t('serverAdded') })\n\n      if (config.enabled) {\n        try {\n          await mcpServerManager.connectServer(config)\n        } catch (error) {\n          console.error('Failed to auto-connect after save:', error)\n        }\n      }\n    }\n\n    onOpenChange(false)\n  }\n\n  const unsupportedMobileSection = isUnsupportedMobileStdio ? (\n    <Card className=\"p-4 border-dashed\">\n      <div className=\"space-y-1\">\n        <div className=\"flex items-center gap-2\">\n          <AlertTriangle className=\"size-4 text-amber-500\" />\n          <p className=\"font-medium\">{t('mobileHttpOnlyTitle')}</p>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('mobileHttpOnlyDesc')}</p>\n      </div>\n    </Card>\n  ) : null\n  \n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={open} onOpenChange={onOpenChange}>\n          <DrawerContent className=\"max-h-[85vh]\">\n            <DrawerHeader>\n              <DrawerTitle>\n                {editingServer ? t('editServer') : t('addServer')}\n              </DrawerTitle>\n            </DrawerHeader>\n\n            <div className=\"space-y-4 px-4 overflow-y-auto\">\n              {/* 服务器名称 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"name\">{t('serverName')}</Label>\n                <Input\n                  id=\"name\"\n                  value={name}\n                  onChange={(e) => setName(e.target.value)}\n                  placeholder={t('serverNamePlaceholder')}\n                />\n              </div>\n\n              {/* 启用状态 */}\n              <div className=\"flex items-center justify-between\">\n                <div className=\"space-y-0.5\">\n                  <Label>{t('serverEnabled')}</Label>\n                  <p className=\"text-xs text-muted-foreground\">{t('serverEnabledDesc')}</p>\n                </div>\n                <Switch\n                  checked={enabled}\n                  onCheckedChange={setEnabled}\n                />\n              </div>\n\n              {/* 服务器类型 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"type\">{t('serverType')}</Label>\n                {isUnsupportedMobileStdio ? (\n                  <Input value={t('stdio')} disabled />\n                ) : (\n                  <Select value={type} onValueChange={(v) => setType(v as MCPServerType)}>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"http\">{t('http')}</SelectItem>\n                      {!isActualMobile && <SelectItem value=\"stdio\">{t('stdio')}</SelectItem>}\n                    </SelectContent>\n                  </Select>\n                )}\n              </div>\n\n              {unsupportedMobileSection}\n\n              {/* stdio 配置 */}\n              {type === 'stdio' && !isActualMobile && (\n                <>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"command\">{t('command')}</Label>\n                    <Input\n                      id=\"command\"\n                      value={command}\n                      onChange={(e) => setCommand(e.target.value)}\n                      placeholder=\"npx @modelcontextprotocol/server-filesystem\"\n                    />\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"args\">{t('args')}</Label>\n                    <Input\n                      id=\"args\"\n                      value={args}\n                      onChange={(e) => setArgs(e.target.value)}\n                      placeholder=\"/path/to/directory\"\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('argsDesc')}</p>\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"env\">{t('env')}</Label>\n                    <Textarea\n                      id=\"env\"\n                      value={env}\n                      onChange={(e) => setEnv(e.target.value)}\n                      placeholder='{\"KEY\": \"value\"}'\n                      rows={3}\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('envDesc')}</p>\n                  </div>\n                </>\n              )}\n\n              {/* HTTP 配置 */}\n              {type === 'http' && (\n                <>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"url\">{t('url')}</Label>\n                    <Input\n                      id=\"url\"\n                      value={url}\n                      onChange={(e) => setUrl(e.target.value)}\n                      placeholder=\"http://localhost:3000/mcp\"\n                    />\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"headers\">{t('headers')}</Label>\n                    <Textarea\n                      id=\"headers\"\n                      value={headers}\n                      onChange={(e) => setHeaders(e.target.value)}\n                      placeholder='{\"Authorization\": \"Bearer token\"}'\n                      rows={3}\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('headersDesc')}</p>\n                  </div>\n                </>\n              )}\n            </div>\n\n            <DrawerFooter>\n              <Button\n                variant=\"outline\"\n                onClick={handleTestConnection}\n                disabled={testing || isUnsupportedMobileStdio}\n              >\n                {testing && <Loader2 className=\"mr-2 size-4 animate-spin\" />}\n                {t('testConnection')}\n              </Button>\n              <Button onClick={handleSave} disabled={isUnsupportedMobileStdio}>{t('save')}</Button>\n            </DrawerFooter>\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={open} onOpenChange={onOpenChange}>\n          <DialogContent className=\"max-w-2xl max-h-[80vh] overflow-y-auto\">\n            <DialogHeader>\n              <DialogTitle>\n                {editingServer ? t('editServer') : t('addServer')}\n              </DialogTitle>\n            </DialogHeader>\n\n            <div className=\"space-y-4 py-4\">\n              {/* 服务器名称 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"name\">{t('serverName')}</Label>\n                <Input\n                  id=\"name\"\n                  value={name}\n                  onChange={(e) => setName(e.target.value)}\n                  placeholder={t('serverNamePlaceholder')}\n                />\n              </div>\n\n              {/* 启用状态 */}\n              <div className=\"flex items-center justify-between\">\n                <div className=\"space-y-0.5\">\n                  <Label>{t('serverEnabled')}</Label>\n                  <p className=\"text-xs text-muted-foreground\">{t('serverEnabledDesc')}</p>\n                </div>\n                <Switch\n                  checked={enabled}\n                  onCheckedChange={setEnabled}\n                />\n              </div>\n\n              {/* 服务器类型 */}\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"type\">{t('serverType')}</Label>\n                {isUnsupportedMobileStdio ? (\n                  <Input value={t('stdio')} disabled />\n                ) : (\n                  <Select value={type} onValueChange={(v) => setType(v as MCPServerType)}>\n                    <SelectTrigger>\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"http\">{t('http')}</SelectItem>\n                      {!isActualMobile && <SelectItem value=\"stdio\">{t('stdio')}</SelectItem>}\n                    </SelectContent>\n                  </Select>\n                )}\n              </div>\n\n              {unsupportedMobileSection}\n\n              {/* stdio 配置 */}\n              {type === 'stdio' && !isActualMobile && (\n                <>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"command\">{t('command')}</Label>\n                    <Input\n                      id=\"command\"\n                      value={command}\n                      onChange={(e) => setCommand(e.target.value)}\n                      placeholder=\"npx @modelcontextprotocol/server-filesystem\"\n                    />\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"args\">{t('args')}</Label>\n                    <Input\n                      id=\"args\"\n                      value={args}\n                      onChange={(e) => setArgs(e.target.value)}\n                      placeholder=\"/path/to/directory\"\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('argsDesc')}</p>\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"env\">{t('env')}</Label>\n                    <Textarea\n                      id=\"env\"\n                      value={env}\n                      onChange={(e) => setEnv(e.target.value)}\n                      placeholder='{\"KEY\": \"value\"}'\n                      rows={3}\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('envDesc')}</p>\n                  </div>\n                </>\n              )}\n\n              {/* HTTP 配置 */}\n              {type === 'http' && (\n                <>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"url\">{t('url')}</Label>\n                    <Input\n                      id=\"url\"\n                      value={url}\n                      onChange={(e) => setUrl(e.target.value)}\n                      placeholder=\"http://localhost:3000/mcp\"\n                    />\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"headers\">{t('headers')}</Label>\n                    <Textarea\n                      id=\"headers\"\n                      value={headers}\n                      onChange={(e) => setHeaders(e.target.value)}\n                      placeholder='{\"Authorization\": \"Bearer token\"}'\n                      rows={3}\n                    />\n                    <p className=\"text-xs text-muted-foreground\">{t('headersDesc')}</p>\n                  </div>\n                </>\n              )}\n            </div>\n\n            <DialogFooter>\n              <Button\n                variant=\"outline\"\n                onClick={handleTestConnection}\n                disabled={testing || isUnsupportedMobileStdio}\n              >\n                {testing && <Loader2 className=\"mr-2 size-4 animate-spin\" />}\n                {t('testConnection')}\n              </Button>\n              <Button onClick={handleSave} disabled={isUnsupportedMobileStdio}>{t('save')}</Button>\n            </DialogFooter>\n          </DialogContent>\n        </Dialog>\n      )}\n\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/server-list.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Card } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport {\n  Plus,\n  Pencil,\n  Trash2,\n  Terminal,\n  Globe,\n  CircleDot,\n  Wrench,\n  ChevronDown,\n  ChevronUp,\n  Loader2,\n  FileJson,\n} from 'lucide-react'\nimport { useMcpStore } from '@/stores/mcp'\nimport { ServerConfigDialog } from './server-config-dialog'\nimport { JsonImportDialog } from './json-import-dialog'\nimport type { MCPServerConfig } from '@/lib/mcp/types'\nimport { useToast } from '@/hooks/use-toast'\nimport { mcpServerManager } from '@/lib/mcp/server-manager'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\n\nexport function ServerList() {\n  const t = useTranslations('settings.mcp')\n  const { toast } = useToast()\n  const { servers, deleteServer, getServerState } = useMcpStore()\n  \n  const [dialogOpen, setDialogOpen] = useState(false)\n  const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null)\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n  const [jsonImportOpen, setJsonImportOpen] = useState(false)\n  const [serverToDelete, setServerToDelete] = useState<string | null>(null)\n  const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set())\n  const [testingAll, setTestingAll] = useState(false)\n  \n  const handleAddServer = () => {\n    setEditingServer(null)\n    setDialogOpen(true)\n  }\n  \n  const handleEditServer = (server: MCPServerConfig) => {\n    setEditingServer(server)\n    setDialogOpen(true)\n  }\n  \n  const handleDeleteClick = (serverId: string) => {\n    setServerToDelete(serverId)\n    setDeleteDialogOpen(true)\n  }\n  \n  const handleDeleteConfirm = async () => {\n    if (serverToDelete) {\n      await mcpServerManager.disconnectServer(serverToDelete)\n      await deleteServer(serverToDelete)\n      toast({ description: t('serverDeleted') })\n      setServerToDelete(null)\n    }\n    setDeleteDialogOpen(false)\n  }\n  \n  const getStatusColor = (serverId: string) => {\n    const state = getServerState(serverId)\n    if (!state) return 'text-muted-foreground'\n    \n    switch (state.status) {\n      case 'connected':\n        return 'text-green-500'\n      case 'connecting':\n        return 'text-yellow-500'\n      case 'error':\n        return 'text-red-500'\n      default:\n        return 'text-muted-foreground'\n    }\n  }\n  \n  const getStatusText = (serverId: string) => {\n    const state = getServerState(serverId)\n    if (!state) return t('disconnected')\n    \n    switch (state.status) {\n      case 'connected':\n        return t('connected')\n      case 'connecting':\n        return t('connecting')\n      case 'error':\n        return t('error')\n      default:\n        return t('disconnected')\n    }\n  }\n  \n  const toggleServerExpanded = (serverId: string) => {\n    setExpandedServers(prev => {\n      const newSet = new Set(prev)\n      if (newSet.has(serverId)) {\n        newSet.delete(serverId)\n      } else {\n        newSet.add(serverId)\n      }\n      return newSet\n    })\n  }\n  \n  const handleTestAllConnections = async () => {\n    setTestingAll(true)\n    const enabledServers = servers.filter(s => s.enabled)\n    \n    try {\n      const result = await mcpServerManager.testConnections(enabledServers)\n\n      const description = result.failed === 0\n        ? t('testAllCompleted')\n        : `${t('testAllCompleted')} (${result.success}/${result.total})`\n\n      toast({ \n        description,\n        variant: result.failed === 0 ? 'default' : 'destructive'\n      })\n    } catch {\n      toast({ \n        description: t('testAllFailed'),\n        variant: 'destructive'\n      })\n    } finally {\n      setTestingAll(false)\n    }\n  }\n  \n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h3 className=\"text-lg font-medium\">{t('servers')}</h3>\n          <p className=\"text-sm text-muted-foreground\">{t('serversDesc')}</p>\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Button onClick={handleAddServer}>\n          <Plus className=\"mr-2 max-md:hidden size-4\" />\n          {t('addServer')}\n        </Button>\n        <Button variant=\"outline\" onClick={() => setJsonImportOpen(true)}>\n          <FileJson className=\"mr-2 max-md:hidden size-4\" />\n          {t('importJson')}\n        </Button>\n        {servers.filter(s => s.enabled).length > 0 && (\n          <Button\n            variant=\"outline\"\n            onClick={handleTestAllConnections}\n            disabled={testingAll}\n          >\n            {testingAll && <Loader2 className=\"mr-2 size-4 animate-spin\" />}\n            {t('testAll')}\n          </Button>\n        )}\n      </div>\n      \n      {servers.length === 0 ? (\n        <Card className=\"p-8 text-center\">\n          <p className=\"text-muted-foreground\">{t('noServers')}</p>\n          <Button onClick={handleAddServer} className=\"mt-4\">\n            <Plus className=\"mr-2 size-4\" />\n            {t('addFirstServer')}\n          </Button>\n        </Card>\n      ) : (\n        <div className=\"space-y-3\">\n          {servers.map((server) => {\n            const state = getServerState(server.id)\n            const toolCount = state?.tools.length || 0\n            \n            const isExpanded = expandedServers.has(server.id)\n            const hasTools = toolCount > 0\n            \n            return (\n              <Card key={server.id} className=\"p-4\">\n                <div className=\"space-y-3\">\n                  <div className=\"flex items-start justify-between\">\n                    <div className=\"flex-1 space-y-2\">\n                      <div className=\"flex items-center gap-2\">\n                        {server.type === 'stdio' ? (\n                          <Terminal className=\"size-4 text-muted-foreground\" />\n                        ) : (\n                          <Globe className=\"size-4 text-muted-foreground\" />\n                        )}\n                        <h4 className=\"font-medium\">{server.name}</h4>\n                        <Badge variant=\"outline\" className=\"text-xs\">\n                          {server.type === 'stdio' ? t('stdio') : t('http')}\n                        </Badge>\n                      </div>\n                      \n                      <div className=\"flex items-center gap-4 text-sm\">\n                        <div className=\"flex items-center gap-1\">\n                          <CircleDot className={`size-3 ${getStatusColor(server.id)}`} />\n                          <span className=\"text-muted-foreground\">\n                            {getStatusText(server.id)}\n                          </span>\n                        </div>\n                        \n                        {hasTools && (\n                          <button\n                            onClick={() => toggleServerExpanded(server.id)}\n                            className=\"flex items-center gap-1 hover:text-foreground transition-colors\"\n                          >\n                            <Wrench className=\"size-3 text-muted-foreground\" />\n                            <span className=\"text-muted-foreground\">\n                              {toolCount} {t('tools')}\n                            </span>\n                            {isExpanded ? (\n                              <ChevronUp className=\"size-3 text-muted-foreground\" />\n                            ) : (\n                              <ChevronDown className=\"size-3 text-muted-foreground\" />\n                            )}\n                          </button>\n                        )}\n                      </div>\n                      \n                      {server.type === 'stdio' && server.command && (\n                        <p className=\"text-xs text-muted-foreground font-mono\">\n                          {server.command} {server.args?.join(' ')}\n                        </p>\n                      )}\n                      \n                      {server.type === 'http' && server.url && (\n                        <p className=\"text-xs text-muted-foreground\">\n                          {server.url}\n                        </p>\n                      )}\n                    </div>\n                    \n                    <div className=\"flex items-center gap-2\">\n                      <Button\n                        size=\"sm\"\n                        variant=\"ghost\"\n                        onClick={() => handleEditServer(server)}\n                      >\n                        <Pencil className=\"size-4\" />\n                      </Button>\n                      \n                      <Button\n                        size=\"sm\"\n                        variant=\"ghost\"\n                        onClick={() => handleDeleteClick(server.id)}\n                      >\n                        <Trash2 className=\"size-4\" />\n                      </Button>\n                    </div>\n                  </div>\n                  \n                  {/* 工具列表 */}\n                  {hasTools && isExpanded && state && (\n                    <div className=\"pt-3 border-t space-y-2\">\n                      {state.tools.map((tool, index) => (\n                        <div\n                          key={`${tool.name}-${index}`}\n                          className=\"p-3 rounded-lg bg-muted/50 space-y-1\"\n                        >\n                          <div className=\"flex items-center gap-2\">\n                            <code className=\"text-sm font-mono\">{tool.name}</code>\n                          </div>\n                          {tool.description && (\n                            <p className=\"text-xs text-muted-foreground\">\n                              {tool.description}\n                            </p>\n                          )}\n                          {tool.inputSchema.properties && (\n                            <div className=\"text-xs text-muted-foreground\">\n                              <span className=\"font-medium\">{t('parameters')}: </span>\n                              {Object.keys(tool.inputSchema.properties).join(', ')}\n                            </div>\n                          )}\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              </Card>\n            )\n          })}\n        </div>\n      )}\n      \n      <ServerConfigDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n        editingServer={editingServer}\n      />\n\n      <JsonImportDialog\n        open={jsonImportOpen}\n        onOpenChange={setJsonImportOpen}\n      />\n\n      <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t('deleteServerTitle')}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {t('deleteServerDesc')}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteConfirm}>\n              {t('delete')}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/mcp/tool-browser.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Card } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Search, Wrench, ChevronDown, ChevronUp } from 'lucide-react'\nimport { useMcpStore } from '@/stores/mcp'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\n\nexport function ToolBrowser() {\n  const t = useTranslations('settings.mcp')\n  const { servers, getServerState } = useMcpStore()\n  const [searchQuery, setSearchQuery] = useState('')\n  const [isOpen, setIsOpen] = useState(false)\n  \n  // 获取所有工具\n  const allTools = servers.flatMap(server => {\n    const state = getServerState(server.id)\n    if (!state || state.status !== 'connected') return []\n    \n    return state.tools.map(tool => ({\n      serverName: server.name,\n      serverId: server.id,\n      tool,\n    }))\n  })\n  \n  // 过滤工具\n  const filteredTools = allTools.filter(({ tool }) => {\n    if (!searchQuery.trim()) return true\n    const query = searchQuery.toLowerCase()\n    return (\n      tool.name.toLowerCase().includes(query) ||\n      tool.description?.toLowerCase().includes(query)\n    )\n  })\n  \n  if (allTools.length === 0) {\n    return null\n  }\n  \n  return (\n    <Collapsible open={isOpen} onOpenChange={setIsOpen}>\n      <Card className=\"p-4\">\n        <CollapsibleTrigger asChild>\n          <Button variant=\"ghost\" className=\"w-full justify-between p-0 h-auto\">\n            <div className=\"flex items-center gap-2\">\n              <Wrench className=\"size-4\" />\n              <span className=\"font-medium\">{t('toolBrowser')}</span>\n              <Badge variant=\"secondary\">{allTools.length}</Badge>\n            </div>\n            {isOpen ? (\n              <ChevronUp className=\"size-4\" />\n            ) : (\n              <ChevronDown className=\"size-4\" />\n            )}\n          </Button>\n        </CollapsibleTrigger>\n        \n        <CollapsibleContent className=\"mt-4 space-y-3\">\n          {/* 搜索框 */}\n          <div className=\"relative\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground\" />\n            <Input\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              placeholder={t('searchTools')}\n              className=\"pl-9\"\n            />\n          </div>\n          \n          {/* 工具列表 */}\n          {filteredTools.length === 0 ? (\n            <p className=\"text-sm text-muted-foreground text-center py-4\">\n              {t('noToolsFound')}\n            </p>\n          ) : (\n            <div className=\"space-y-2 max-h-96 overflow-y-auto\">\n              {filteredTools.map(({ serverName, tool }, index) => (\n                <Card key={`${serverName}-${tool.name}-${index}`} className=\"p-3\">\n                  <div className=\"space-y-1\">\n                    <div className=\"flex items-center gap-2\">\n                      <code className=\"text-sm font-mono\">{tool.name}</code>\n                      <Badge variant=\"outline\" className=\"text-xs\">\n                        {serverName}\n                      </Badge>\n                    </div>\n                    {tool.description && (\n                      <p className=\"text-xs text-muted-foreground\">\n                        {tool.description}\n                      </p>\n                    )}\n                    {tool.inputSchema.properties && (\n                      <div className=\"text-xs text-muted-foreground\">\n                        <span className=\"font-medium\">{t('parameters')}: </span>\n                        {Object.keys(tool.inputSchema.properties).join(', ')}\n                      </div>\n                    )}\n                  </div>\n                </Card>\n              ))}\n            </div>\n          )}\n        </CollapsibleContent>\n      </Card>\n    </Collapsible>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/memories/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { SettingType } from '../components/setting-base'\nimport { Brain } from 'lucide-react'\nimport { MemoryList } from '@/components/memories/memory-list'\n\nexport default function MemoriesSettingsPage() {\n  const t = useTranslations('settings.memories')\n\n  return (\n    <SettingType\n      id=\"memories\"\n      title={t('title')}\n      desc={t('desc')}\n      icon={<Brain className=\"size-4 lg:size-6\" />}\n    >\n      <MemoryList />\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/page.tsx",
    "content": "'use client'\nimport { useEffect } from 'react'\nimport baseConfig from \"./config\"\nimport useSettingStore from '@/stores/setting'\nimport { redirect } from 'next/navigation'\n\nexport default function Page() {\n  const { lastSettingPage } = useSettingStore()\n  \n  useEffect(() => {\n    // 重定向到最后访问的设置页面，如果没有则使用第一个设置项\n    const targetPage = lastSettingPage || baseConfig[0].anchor\n    const hasPage = baseConfig.some(item => item.anchor === targetPage)\n    if (!hasPage) {\n      redirect(`/core/setting/${baseConfig[0].anchor}`)\n    }\n    redirect(`/core/setting/${targetPage}`)\n  }, [])\n  \n  // 渲染一个加载中状态，在重定向之前显示\n  return <div className=\"flex items-center justify-center h-full\">\n    <p className=\"text-gray-500\">加载中...</p>\n  </div>\n}"
  },
  {
    "path": "src/app/core/setting/prompt/page.tsx",
    "content": "'use client'\n\nimport { SettingPrompt } from './setting-prompt'\nimport { Drama } from 'lucide-react'\n\nexport default function PromptSetting() {\n  return <SettingPrompt id=\"prompt\" icon={<Drama />} />\n}\n"
  },
  {
    "path": "src/app/core/setting/prompt/setting-prompt.tsx",
    "content": "'use client'\nimport { SettingType } from '../components/setting-base'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { CardContent, Card } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Plus, Trash, Pencil, Check, X, Sparkles } from 'lucide-react'\nimport { useEffect, useState } from 'react'\nimport usePromptStore, { Prompt } from '@/stores/prompt'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog'\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from '@/components/ui/drawer'\nimport { Label } from '@/components/ui/label'\nimport { OpenBroswer } from '@/components/open-broswer'\nimport { fetchAi } from '@/lib/ai/chat'\nimport { toast } from '@/hooks/use-toast'\nimport { useI18n } from '@/hooks/useI18n'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\n\nexport function SettingPrompt({id, icon}: {id: string, icon?: React.ReactNode}) {\n  const t = useTranslations('settings')\n  const { currentLocale } = useI18n();\n  const commonT = useTranslations('common')\n  const { promptList, initPromptData, addPrompt, updatePrompt, deletePrompt } = usePromptStore()\n  const [editingId, setEditingId] = useState<string | null>(null)\n  const [newTitle, setNewTitle] = useState('')\n  const [newContent, setNewContent] = useState('')\n  const [dialogOpen, setDialogOpen] = useState(false)\n  const [isOptimizing, setIsOptimizing] = useState(false)\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n\n  useEffect(() => {\n    initPromptData()\n  }, [])\n\n  // 添加新prompt\n  const handleAddPrompt = async () => {\n    if (!newTitle.trim()) return\n    await addPrompt({\n      title: newTitle,\n      content: newContent\n    })\n    // 清空表单\n    setNewTitle('')\n    setNewContent('')\n    setDialogOpen(false)\n  }\n\n  // 保存编辑中的prompt\n  const handleSaveEdit = async (id: string) => {\n    const prompt = promptList.find(p => p.id === id)\n    if (!prompt) return\n\n    if (!newTitle.trim()) return\n    await updatePrompt({\n      ...prompt,\n      title: newTitle,\n      content: newContent\n    })\n    setEditingId(null)\n  }\n\n  // 取消编辑\n  const handleCancelEdit = () => {\n    setEditingId(null)\n  }\n\n  // 开始编辑\n  const handleStartEdit = (prompt: Prompt) => {\n    setEditingId(prompt.id)\n    setNewTitle(prompt.title)\n    setNewContent(prompt.content)\n  }\n\n  // 删除prompt\n  const handleDeletePrompt = async (id: string) => {\n    await deletePrompt(id)\n  }\n\n  // 优化提示词\n  const handleOptimizePrompt = async () => {\n    if (!newContent.trim()) {\n      toast({\n        description: t('prompt.noContentToOptimize'),\n        variant: 'destructive'\n      })\n      return\n    }\n\n    setIsOptimizing(true)\n    try {\n      const optimizationPrompt = `\n      Please optimize the following prompt, use ${currentLocale} language, making it clearer, more specific, and more effective. \n      Maintain the original meaning while improving expression, adding necessary context, optimizing structure and logic. \n      Please directly return the optimized prompt content, without adding any additional explanation:\n\n${newContent}`\n      \n      const optimizedContent = await fetchAi(optimizationPrompt)\n      if (optimizedContent) {\n        setNewContent(optimizedContent)\n        toast({\n          description: t('prompt.optimizeSuccess')\n        })\n      } else {\n        toast({\n          description: t('prompt.optimizeFailed'),\n          variant: 'destructive'\n        })\n      }\n    } catch {\n      toast({\n        description: t('prompt.optimizeFailed'),\n        variant: 'destructive'\n      })\n    } finally {\n      setIsOptimizing(false)\n    }\n  }\n\n  // 打开新增对话框\n  const handleOpenAddDialog = () => {\n    setNewTitle('')\n    setNewContent('')\n    setDialogOpen(true)\n  }\n\n  return (\n    <SettingType id={id} title={t('prompt.title')} desc={t('prompt.desc')} icon={icon}>\n      <div className=\"flex flex-col gap-4\">\n        <div className=\"flex justify-between items-center\">\n          {isMobile ? (\n            <Drawer open={dialogOpen} onOpenChange={setDialogOpen}>\n              <DrawerTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" onClick={handleOpenAddDialog}>\n                  <Plus className=\"h-4 w-4 mr-2\" />\n                  {t('prompt.addPrompt')}\n                </Button>\n              </DrawerTrigger>\n              <DrawerContent>\n                <DrawerHeader>\n                  <DrawerTitle>\n                    {t('prompt.addPrompt')}\n                  </DrawerTitle>\n                  <DrawerDescription>\n                    {t('prompt.addPromptDesc')}\n                  </DrawerDescription>\n                </DrawerHeader>\n                <div className=\"grid gap-4 px-4\">\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"title\">{t('prompt.promptTitle')}</Label>\n                    <Input\n                      id=\"title\"\n                      value={newTitle}\n                      onChange={(e) => setNewTitle(e.target.value)}\n                      placeholder={t('prompt.promptTitlePlaceholder')}\n                    />\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"content\">{t('prompt.promptContent')}</Label>\n                    <div className=\"space-y-2\">\n                      <Textarea\n                        id=\"content\"\n                        value={newContent}\n                        onChange={(e) => setNewContent(e.target.value)}\n                        placeholder={t('prompt.promptContentPlaceholder')}\n                        rows={5}\n                      />\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={handleOptimizePrompt}\n                        disabled={isOptimizing || !newContent.trim()}\n                        className=\"w-full\"\n                      >\n                        <Sparkles className=\"h-4 w-4 mr-2\" />\n                        {isOptimizing ? t('prompt.optimizing') : t('prompt.optimizePrompt')}\n                      </Button>\n                    </div>\n                  </div>\n                </div>\n                <DrawerFooter>\n                  <Button variant=\"outline\" onClick={() => setDialogOpen(false)}>{commonT('cancel')}</Button>\n                  <Button onClick={handleAddPrompt}>{commonT('confirm')}</Button>\n                </DrawerFooter>\n              </DrawerContent>\n            </Drawer>\n          ) : (\n            <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>\n              <DialogTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" onClick={handleOpenAddDialog}>\n                  <Plus className=\"h-4 w-4 mr-2\" />\n                  {t('prompt.addPrompt')}\n                </Button>\n              </DialogTrigger>\n              <DialogContent>\n                <DialogHeader>\n                  <DialogTitle>\n                    {t('prompt.addPrompt')}\n                  </DialogTitle>\n                  <DialogDescription>\n                    {t('prompt.addPromptDesc')}\n                  </DialogDescription>\n                </DialogHeader>\n                <div className=\"grid gap-4 py-4\">\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"title\">{t('prompt.promptTitle')}</Label>\n                    <Input\n                      id=\"title\"\n                      value={newTitle}\n                      onChange={(e) => setNewTitle(e.target.value)}\n                      placeholder={t('prompt.promptTitlePlaceholder')}\n                    />\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"content\">{t('prompt.promptContent')}</Label>\n                    <div className=\"space-y-2\">\n                      <Textarea\n                        id=\"content\"\n                        value={newContent}\n                        onChange={(e) => setNewContent(e.target.value)}\n                        placeholder={t('prompt.promptContentPlaceholder')}\n                        rows={5}\n                      />\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={handleOptimizePrompt}\n                        disabled={isOptimizing || !newContent.trim()}\n                        className=\"w-full\"\n                      >\n                        <Sparkles className=\"h-4 w-4 mr-2\" />\n                        {isOptimizing ? t('prompt.optimizing') : t('prompt.optimizePrompt')}\n                      </Button>\n                    </div>\n                  </div>\n                </div>\n                <DialogFooter>\n                  <Button variant=\"outline\" onClick={() => setDialogOpen(false)}>{commonT('cancel')}</Button>\n                  <Button onClick={handleAddPrompt}>{commonT('confirm')}</Button>\n                </DialogFooter>\n              </DialogContent>\n            </Dialog>\n          )}\n          <OpenBroswer title=\"Awesome Prompts\" url=\"https://github.com/f/awesome-chatgpt-prompts\" className='text-sm' />\n        </div>\n        <div className=\"grid gap-4\">\n          {promptList.map((prompt) => (\n            <Card key={prompt.id}>\n              <CardContent className=\"p-4\">\n                {editingId === prompt.id ? (\n                  <div className=\"flex flex-col gap-4\">\n                    <Input\n                      value={newTitle}\n                      onChange={(e) => setNewTitle(e.target.value)}\n                      placeholder={t('prompt.promptTitlePlaceholder')}\n                    />\n                    <div className=\"space-y-2\">\n                      <Textarea\n                        value={newContent}\n                        onChange={(e) => setNewContent(e.target.value)}\n                        placeholder={t('prompt.promptContentPlaceholder')}\n                        rows={5}\n                      />\n                    </div>\n                    <div className=\"flex justify-end gap-2\">\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={handleOptimizePrompt}\n                        disabled={isOptimizing || !newContent.trim()}\n                      >\n                        <Sparkles className=\"h-4 w-4 mr-2\" />\n                        {isOptimizing ? t('prompt.optimizing') : t('prompt.optimizePrompt')}\n                      </Button>\n                      <Button \n                        variant=\"outline\" \n                        size=\"sm\" \n                        onClick={handleCancelEdit}\n                      >\n                        <X className=\"h-4 w-4 mr-2\" />\n                        {commonT('cancel')}\n                      </Button>\n                      <Button \n                        size=\"sm\" \n                        onClick={() => handleSaveEdit(prompt.id)}\n                      >\n                        <Check className=\"h-4 w-4 mr-2\" />\n                        {commonT('save')}\n                      </Button>\n                    </div>\n                  </div>\n                ) : (\n                  <div className=\"flex flex-col gap-2\">\n                    <div className=\"flex justify-between items-center\">\n                      <h3 className=\"font-medium\">{prompt.title}</h3>\n                      <div className=\"flex gap-2\">\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => handleStartEdit(prompt)}\n                        >\n                          <Pencil className=\"h-4 w-4\" />\n                        </Button>\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => handleDeletePrompt(prompt.id)}\n                          disabled={prompt.isDefault}\n                        >\n                          <Trash className=\"h-4 w-4\" />\n                        </Button>\n                      </div>\n                    </div>\n                    <p className=\"text-sm text-muted-foreground whitespace-pre-wrap line-clamp-3\">\n                      {prompt.content || t('prompt.noContent')}\n                    </p>\n                  </div>\n                )}\n              </CardContent>\n            </Card>\n          ))}\n        </div>\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/rag/model-setting.tsx",
    "content": "import { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { ModelSelect } from \"../components/model-select\";\nimport { ChartScatter, ListOrdered } from \"lucide-react\";\n\nexport function ModelSetting() {\n  const t = useTranslations('settings.defaultModel');\n  \n  const modelOptions = [\n    {\n      title: t('options.embedding.title'),\n      desc: t('options.embedding.desc'),\n      modelKey: 'embedding',\n      icon: <ChartScatter className=\"size-4\" />\n    },\n    {\n      title: t('options.reranking.title'),\n      desc: t('options.reranking.desc'),\n      modelKey: 'reranking',\n      icon: <ListOrdered className=\"size-4\" />\n    },\n  ];\n\n  return (\n    <ItemGroup className=\"gap-4\">\n      {\n        modelOptions.map((option) => (\n          <Item key={option.modelKey} className='max-md:flex-col max-md:items-start' variant=\"outline\">\n            <ItemMedia variant=\"icon\">{option.icon}</ItemMedia>\n            <ItemContent>\n              <ItemTitle>{option.title}</ItemTitle>\n              <ItemDescription>{option.desc}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ModelSelect modelKey={option.modelKey} />\n            </ItemActions>\n          </Item>\n        ))\n      }\n    </ItemGroup>\n  )\n}"
  },
  {
    "path": "src/app/core/setting/rag/page.tsx",
    "content": "'use client'\n\nimport { SettingType } from '../components/setting-base'\nimport { Drama } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { ModelSetting } from './model-setting'\nimport { Settings } from './settings'\n\nexport default function PromptSetting() {\n  const t = useTranslations('settings.rag')\n  return <SettingType id=\"rag\" title={t('title')} desc={t('desc')} icon={<Drama />}>\n    <ModelSetting />\n    <Settings />\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/rag/settings.tsx",
    "content": "import { useTranslations } from 'next-intl';\nimport { RefreshCw, Trash, FileText, Layers, Hash, Target } from \"lucide-react\";\nimport useRagSettingsStore from \"@/stores/ragSettings\";\nimport { FormItem } from \"../components/setting-base\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Button } from \"@/components/ui/button\";\nimport { useEffect } from \"react\";\nimport { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemActions, ItemDescription } from '@/components/ui/item';\nimport { clearVectorDb, initVectorDb } from \"@/db/vector\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { confirm } from \"@tauri-apps/plugin-dialog\";\n\nexport function Settings() {\n  const t = useTranslations('settings.rag');\n  \n  const { \n    chunkSize, \n    chunkOverlap, \n    resultCount, \n    similarityThreshold,\n    initSettings,\n    updateSetting,\n    resetToDefaults\n  } = useRagSettingsStore();\n\n  useEffect(() => {\n    initSettings();\n  }, []);\n\n  function handleDeleteVector() {\n    confirm(t('deleteVectorConfirm')).then(async (result) => {\n      if (result) {\n        await clearVectorDb()\n        await initVectorDb()\n        toast({\n          title: t('deleteVectorSuccess'),\n          variant: 'default',\n        })\n      }\n    })\n  }\n\n  const settings = [\n    {\n      title: t('chunkSize'),\n      desc: t('chunkSizeDesc'),\n      value: chunkSize,\n      min: 100,\n      max: 5000,\n      step: 100,\n      icon: FileText,\n      onChange: (value: number) => updateSetting('chunkSize', value)\n    },\n    {\n      title: t('chunkOverlap'),\n      desc: t('chunkOverlapDesc'),\n      value: chunkOverlap,\n      min: 0,\n      max: 500,\n      step: 50,\n      icon: Layers,\n      onChange: (value: number) => updateSetting('chunkOverlap', value)\n    },\n    {\n      title: t('resultCount'),\n      desc: t('resultCountDesc'),\n      value: resultCount,\n      min: 1,\n      max: 10,\n      step: 1,\n      icon: Hash,\n      onChange: (value: number) => updateSetting('resultCount', value)\n    },\n    {\n      title: t('similarityThreshold'),\n      desc: t('similarityThresholdDesc'),\n      value: similarityThreshold,\n      min: 0,\n      max: 1,\n      step: 0.01,\n      icon: Target,\n      onChange: (value: number) => updateSetting('similarityThreshold', value)\n    }\n  ]\n\n  return (\n    <>\n      <FormItem title={t('settingsTitle')}>\n        <ItemGroup className=\"gap-4\">\n          {settings.map((setting) => {\n            const Icon = setting.icon\n            return (\n            <Item key={setting.title} className=\"max-md:flex-col max-md:items-start\" variant=\"outline\">\n              <ItemMedia variant=\"icon\">\n                <Icon className=\"size-4\" />\n              </ItemMedia>\n              <ItemContent>\n                <ItemTitle>{setting.title}</ItemTitle>\n                <ItemDescription>{setting.desc}</ItemDescription>\n              </ItemContent>\n              <ItemActions className=\"w-[180px] max-md:w-full\">\n                <div className=\"space-y-3 w-full\">\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"text-xs text-muted-foreground\">{setting.min}</span>\n                    <span className=\"text-xs font-medium\">{setting.value}</span>\n                    <span className=\"text-xs text-muted-foreground\">{setting.max}</span>\n                  </div>\n                  <Slider\n                    value={[setting.value]}\n                    onValueChange={(value) => setting.onChange(value[0])}\n                    min={setting.min}\n                    max={setting.max}\n                    step={setting.step}\n                    className=\"w-full\"\n                  />\n                </div>\n              </ItemActions>\n            </Item>\n          )\n          })}\n        </ItemGroup>\n      </FormItem>\n      <div className=\"flex gap-2 mt-4\">\n        <Button variant=\"outline\" onClick={resetToDefaults}>\n          <RefreshCw className=\"size-4 mr-2\" /> {t('resetToDefaults')}\n        </Button>\n        <Button variant=\"destructive\" onClick={handleDeleteVector}>\n          <Trash className=\"size-4 mr-2\" /> {t('deleteVector')}\n        </Button>\n      </div>\n    </>\n  );\n}"
  },
  {
    "path": "src/app/core/setting/readAloud/page.tsx",
    "content": "'use client';\nimport { SettingType } from \"../components/setting-base\";\nimport { Setting } from \"./setting\";\nimport { Volume2 } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\";\n\nexport default function ReadAloudPage() {\n  const t = useTranslations('settings.readAloud');\n\n  return <SettingType id=\"readAloud\" icon={<Volume2 />} title={t('title')} desc={t('desc')}>\n    <Setting />\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/readAloud/setting.tsx",
    "content": "import { Item, ItemGroup, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from 'next-intl';\nimport { ModelSelect } from \"../components/model-select\";\nimport { Gauge, Volume2 } from \"lucide-react\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport type { SpeechMode } from '@/lib/speech/types';\n\nexport function Setting() {\n  const t = useTranslations('settings.readAloud');\n  const { audioModel, textToSpeechMode, setAiModelList, setTextToSpeechMode } = useSettingStore();\n  const [speed, setSpeed] = useState(1);\n  const modeOptions: Array<{ value: SpeechMode; label: string }> = [\n    { value: 'auto', label: t('options.mode.auto') },\n    { value: 'local', label: t('options.mode.local') },\n    { value: 'model', label: t('options.mode.model') },\n  ];\n\n  // 加载语速设置\n  useEffect(() => {\n    async function loadSpeed() {\n      if (!audioModel) return;\n      const store = await Store.load('store.json');\n      const models = await store.get<any[]>('aiModelList');\n      if (!models) return;\n      \n      // 查找音频模型配置，适配新的多模型数据结构\n      let currentSpeed = 1;\n      for (const config of models) {\n        // 检查新的 models 数组结构\n        if (config.models && config.models.length > 0) {\n          const targetModel = config.models.find((model: any) => \n            model.id === audioModel && model.modelType === 'audio'\n          );\n          if (targetModel && targetModel.speed !== undefined) {\n            currentSpeed = targetModel.speed;\n            break;\n          }\n        } else {\n          // 向后兼容：处理旧的单模型结构\n          if (config.key === audioModel && config.modelType === 'audio' && config.speed !== undefined) {\n            currentSpeed = config.speed;\n            break;\n          }\n        }\n      }\n      \n      setSpeed(currentSpeed);\n      setAiModelList(models);\n    }\n    loadSpeed();\n  }, [audioModel]);\n\n  // 保存语速设置\n  const handleSpeedChange = async (value: number[]) => {\n    const newSpeed = value[0];\n    setSpeed(newSpeed);\n    \n    if (!audioModel) return;\n    \n    const store = await Store.load('store.json');\n    const models = await store.get<any[]>('aiModelList') || [];\n    \n    // 更新音频模型的语速设置，适配新的多模型数据结构\n    const updatedModels = models.map(config => {\n      // 检查新的 models 数组结构\n      if (config.models && config.models.length > 0) {\n        const updatedConfig = { ...config };\n        updatedConfig.models = config.models.map((model: any) => {\n          if (model.id === audioModel && model.modelType === 'audio') {\n            return { ...model, speed: newSpeed };\n          }\n          return model;\n        });\n        return updatedConfig;\n      } else {\n        // 向后兼容：处理旧的单模型结构\n        if (config.key === audioModel && config.modelType === 'audio') {\n          return { ...config, speed: newSpeed };\n        }\n        return config;\n      }\n    });\n    \n    setAiModelList(updatedModels);\n    await store.set('aiModelList', updatedModels);\n    await store.save();\n  };\n\n  return (\n    <ItemGroup className=\"gap-4\">\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\"><Volume2 className=\"size-4\" /></ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('options.mode.title')}</ItemTitle>\n          <ItemDescription>{t('options.mode.desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <Select value={textToSpeechMode} onValueChange={(value) => setTextToSpeechMode(value as SpeechMode)}>\n            <SelectTrigger className=\"w-[180px]\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {modeOptions.map((option) => (\n                <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </ItemActions>\n      </Item>\n\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\"><Volume2 className=\"size-4\" /></ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('options.audioModel.title')}</ItemTitle>\n          <ItemDescription>{t('options.audioModel.desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <ModelSelect modelKey=\"audio\" />\n        </ItemActions>\n      </Item>\n      {audioModel && (\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><Gauge className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('options.speed.title')}</ItemTitle>\n            <ItemDescription>{t('options.speed.desc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <div className=\"flex items-center gap-4\">\n              <Slider\n                value={[speed]}\n                onValueChange={handleSpeedChange}\n                min={0.5}\n                max={2}\n                step={0.1}\n                className=\"w-[180px]\"\n              />\n              <span className=\"text-zinc-500 w-10\">{speed}x</span>\n            </div>\n          </ItemActions>\n        </Item>\n      )}\n    </ItemGroup>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/record/model-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemTitle,\n  ItemDescription,\n  ItemActions,\n} from '@/components/ui/item'\nimport { PenTool } from 'lucide-react'\nimport { ModelSelect } from '../components/model-select'\n\nexport function ModelSettings() {\n  const t = useTranslations('settings.record.model')\n\n  return (\n    <div className=\"space-y-4\">\n      <h3 className=\"text-lg font-semibold mb-4\">{t('title')}</h3>\n\n      <Item variant=\"outline\">\n        <ItemMedia variant=\"icon\">\n          <PenTool className=\"size-4\" />\n        </ItemMedia>\n        <ItemContent>\n          <ItemTitle>{t('markDesc.title')}</ItemTitle>\n          <ItemDescription>{t('markDesc.desc')}</ItemDescription>\n        </ItemContent>\n        <ItemActions>\n          <ModelSelect modelKey=\"markDesc\" />\n        </ItemActions>\n      </Item>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/record/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { SettingType } from '../components/setting-base'\nimport { PenTool } from 'lucide-react'\nimport { DefaultModelsSettings } from '../components/default-models-settings'\nimport { ToolbarSettings } from './toolbar-settings'\n\nexport default function RecordSettingPage() {\n  const t = useTranslations('settings.record')\n\n  return (\n    <SettingType\n      id=\"record\"\n      icon={<PenTool className=\"size-4 lg:size-6\" />}\n      title={t('title')}\n      desc={t('desc')}\n    >\n      <div className=\"space-y-8\">\n        <DefaultModelsSettings type=\"record\" />\n        <ToolbarSettings />\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/record/toolbar-settings.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Switch } from '@/components/ui/switch'\nimport {\n  CopySlash,\n  Mic,\n  ScanLine,\n  ImagePlus,\n  Link2,\n  FileText,\n  CheckSquare,\n  GripVertical\n} from 'lucide-react'\nimport useSettingStore, { RecordToolbarItem } from '@/stores/setting'\nimport {\n  DndContext,\n  closestCenter,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  verticalListSortingStrategy,\n  useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\n// 工具配置：图标和描述键\nconst TOOL_CONFIGS = {\n  text: {\n    icon: <CopySlash className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.text',\n    descKey: 'settings.record.toolbar.recordToolbar.text.desc',\n  },\n  recording: {\n    icon: <Mic className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.recording',\n    descKey: 'settings.record.toolbar.recordToolbar.recording.desc',\n  },\n  scan: {\n    icon: <ScanLine className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.scan',\n    descKey: 'settings.record.toolbar.recordToolbar.scan.desc',\n  },\n  image: {\n    icon: <ImagePlus className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.image',\n    descKey: 'settings.record.toolbar.recordToolbar.image.desc',\n  },\n  link: {\n    icon: <Link2 className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.link',\n    descKey: 'settings.record.toolbar.recordToolbar.link.desc',\n  },\n  file: {\n    icon: <FileText className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.file',\n    descKey: 'settings.record.toolbar.recordToolbar.file.desc',\n  },\n  todo: {\n    icon: <CheckSquare className=\"size-4\" />,\n    titleKey: 'record.mark.toolbar.todo',\n    descKey: 'settings.record.toolbar.recordToolbar.todo.desc',\n  },\n}\n\n// 可排序的工具栏项组件\ninterface SortableItemProps {\n  item: RecordToolbarItem\n  config: typeof TOOL_CONFIGS[keyof typeof TOOL_CONFIGS]\n  onToggle: (id: string) => void\n  t: (key: string) => string\n}\n\nfunction SortableItem({ item, config, onToggle, t }: SortableItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id: item.id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  return (\n    <div ref={setNodeRef} style={style}>\n      <div className=\"flex items-center gap-3 p-3 border rounded-lg bg-background hover:bg-accent/50 transition-colors\">\n        {/* 拖拽句柄 */}\n        <div {...attributes} {...listeners} className=\"cursor-grab active:cursor-grabbing shrink-0\">\n          <GripVertical className=\"size-4 text-muted-foreground\" />\n        </div>\n\n        {/* 工具图标 */}\n        <div className=\"shrink-0 text-muted-foreground\">\n          {config?.icon}\n        </div>\n\n        {/* 标题和描述 */}\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"text-sm font-medium\">{config ? t(config.titleKey) : item.id}</div>\n          <div className=\"text-xs text-muted-foreground truncate\">{config ? t(config.descKey) : ''}</div>\n        </div>\n\n        {/* 开关 */}\n        <div onClick={(e) => e.stopPropagation()} className=\"shrink-0\">\n          <Switch\n            checked={item.enabled}\n            onCheckedChange={() => onToggle(item.id)}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport function ToolbarSettings() {\n  const t = useTranslations()\n  const { recordToolbarConfig, setRecordToolbarConfig } = useSettingStore()\n\n  // 拖拽传感器配置\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 8,\n      },\n    })\n  )\n\n  const handleToggle = async (id: string) => {\n    const newConfig = recordToolbarConfig.map(item =>\n      item.id === id ? { ...item, enabled: !item.enabled } : item\n    )\n    await setRecordToolbarConfig(newConfig)\n  }\n\n  // 处理拖拽结束\n  const handleDragEnd = async (event: DragEndEvent) => {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const oldIndex = recordToolbarConfig.findIndex((item) => item.id === active.id)\n      const newIndex = recordToolbarConfig.findIndex((item) => item.id === over.id)\n      const newItems = arrayMove(recordToolbarConfig, oldIndex, newIndex)\n      const updatedItems = newItems.map((item, index) => ({\n        ...item,\n        order: index,\n      }))\n      await setRecordToolbarConfig(updatedItems)\n    }\n  }\n\n  // 按排序展示工具（过滤掉不在 TOOL_CONFIGS 中的项）\n  const sortedConfig = [...recordToolbarConfig]\n    .filter(item => item.id in TOOL_CONFIGS)\n    .sort((a, b) => a.order - b.order)\n\n  return (\n    <div className=\"space-y-4\">\n      {/* 标题 */}\n      <h3 className=\"text-lg font-semibold\">{t('settings.record.toolbar.title')}</h3>\n\n      <div className=\"space-y-1\">\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCenter}\n        onDragEnd={handleDragEnd}\n      >\n        <SortableContext\n          items={sortedConfig.map(item => item.id)}\n          strategy={verticalListSortingStrategy}\n        >\n          {sortedConfig.map((item) => (\n            <SortableItem\n              key={item.id}\n              item={item}\n              config={TOOL_CONFIGS[item.id as keyof typeof TOOL_CONFIGS]}\n              onToggle={handleToggle}\n              t={t}\n            />\n          ))}\n        </SortableContext>\n      </DndContext>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/shortcuts/page.tsx",
    "content": "'use client';\n\nimport { LayoutTemplate } from \"lucide-react\"\nimport { SettingType } from \"../components/setting-base\";\nimport { Item, ItemGroup, ItemContent, ItemTitle, ItemDescription, ItemActions } from '@/components/ui/item';\nimport { useTranslations } from \"next-intl\";\nimport useShortcutStore from \"@/stores/shortcut\";\nimport ShortcutsInput from \"./shorcut-input\";\n\nexport default function ShortcutsPage() {\n  const t = useTranslations('settings.shortcuts');\n  const { shortcuts } = useShortcutStore()\n\n  return <SettingType id=\"shortcuts\" title={t('title')} desc={t('desc')} icon={<LayoutTemplate />}>\n    <ItemGroup className=\"gap-4\">\n      {\n        shortcuts.map((shortcut) => (\n          <Item key={shortcut.key} variant=\"outline\">\n            <ItemContent>\n              <ItemTitle>{t(`shortcuts.${shortcut.key}.title`)}</ItemTitle>\n              <ItemDescription>{t(`shortcuts.${shortcut.key}.desc`)}</ItemDescription>\n            </ItemContent>\n            <ItemActions>\n              <ShortcutsInput name={shortcut.key} />\n            </ItemActions>\n          </Item>\n        ))\n      }\n    </ItemGroup>\n  </SettingType>\n}\n"
  },
  {
    "path": "src/app/core/setting/shortcuts/shorcut-input.tsx",
    "content": "import { useTranslations } from \"next-intl\";\nimport { TooltipButton } from \"@/components/tooltip-button\";\nimport { ArrowBigUpIcon, CommandIcon, OptionIcon, RotateCcw, TrashIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport useShortcutStore from \"@/stores/shortcut\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { platform } from \"@tauri-apps/plugin-os\";\nimport hotkeys from 'hotkeys-js';\nimport { useClickAway } from 'react-use'\nimport { uniq } from \"lodash-es\";\n\nexport default function ShortcutsInput({\n  name,\n  disabled\n}: {\n  name: string;\n  disabled?: boolean;\n}) {\n  const t = useTranslations('settings.shortcuts');\n  const { shortcuts, setShortcut, resetDefault } = useShortcutStore()\n  const [isFocus, setIsFocus] = useState(false)\n  const [value, setValue] = useState('')\n  const inputRef = useRef<HTMLInputElement>(null)\n  const keys: string[] = []\n\n  const shorcut = useMemo(() => {\n    return shortcuts.find((shortcut) => shortcut.key === name)\n  }, [name, shortcuts]);\n\n  const keyGroup = useMemo(() => value.split('+').filter((key) => key.length), [value])\n\n  async function init() {\n    setValue(shorcut?.value || '')\n  }\n\n  useClickAway(inputRef, async () => {\n    if (isFocus) {\n      setIsFocus(false)\n      hotkeys.unbind('*')\n      await setShortcut(name, value)\n    }\n  })\n\n  async function handleSetFocus() {\n    if (disabled) return\n    setIsFocus(true)\n    hotkeys('*', (event) => {\n      let key = ''\n      switch (event.key) {\n        case 'Meta':\n          key = 'CommandOrControl'\n          break;\n        default:\n          key = event.key.charAt(0).toUpperCase() + event.key.slice(1)\n          break;\n      }\n      keys.push(key)\n      setValue(uniq(keys).join('+'))\n    })\n  }\n\n  async function handleResetDefault() {\n    await resetDefault(name)\n  }\n\n  async function handleClear() {\n    setValue('')\n    await setShortcut(name, '')\n  }\n\n  // 根据系统转化 CommandOrControl\n  function transformKey(key: string) {\n    if (platform() === 'macos') {\n      switch (key) {\n        case 'CommandOrControl':\n          return <CommandIcon className=\"size-3.5\" />\n        case 'Control':\n          return 'Control'\n        case 'Shift':\n          return <ArrowBigUpIcon className=\"size-4\" />\n        case 'Alt':\n          return <OptionIcon className=\"size-3.5\" />\n        default:\n          return key\n      }\n    }\n    switch (key) {\n      case 'CommandOrControl':\n        return 'Ctrl'\n      default:\n        return key\n    }\n  }\n\n  useEffect(() => {\n    init()\n  }, [shorcut, isFocus])\n\n  return <div className=\"flex items-center gap-2\">\n    <div\n      onClick={handleSetFocus}\n      ref={inputRef}\n      className={`\n        flex-1\n        px-2\n        py-1\n        flex\n        rounded-md\n        items-center\n        cursor-pointer\n        border\n        h-9\n        ${isFocus ? 'border-primary' : 'border-transparent'}\n      `}\n    >\n      {\n        keyGroup.length ? keyGroup?.map((key, index) => {\n          if (index < keyGroup.length - 1) {\n            return (\n              <div key={index} className=\"flex items-center\">\n                <Badge variant=\"secondary\" className=\"h-6\">{transformKey(key)}</Badge>\n                <span className=\"px-1 text-xs\">+</span>\n              </div>\n            )\n          } else {\n            return <div className=\"flex items-center\" key={index}><Badge variant=\"secondary\" className=\"h-6\">{transformKey(key)}</Badge></div>\n          }\n        }) : <Badge variant=\"secondary\" className=\"h-6\">{t('noShortcut')}</Badge>\n      }\n    </div>\n    <TooltipButton\n      size=\"icon\"\n      variant=\"ghost\"\n      tooltipText={t('resetDefaults')}\n      onClick={handleResetDefault}\n      icon={<RotateCcw />}\n    />\n    <TooltipButton\n      size=\"icon\"\n      variant=\"destructive\"\n      tooltipText={t('clear')}\n      onClick={handleClear}\n      icon={<TrashIcon />}\n    />\n  </div>\n}"
  },
  {
    "path": "src/app/core/setting/skills/components/global-skills-manager.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Sparkles, Upload, Loader2, Info } from 'lucide-react'\nimport { useSkillsStore } from '@/stores/skills'\nimport { SkillCard } from './skill-card'\nimport { open } from '@tauri-apps/plugin-dialog'\nimport { invoke } from '@tauri-apps/api/core'\nimport { useToast } from '@/hooks/use-toast'\n\nexport function GlobalSkillsManager() {\n  const t = useTranslations('settings.skills')\n  const { toast } = useToast()\n  const { globalSkills, refreshSkills } = useSkillsStore()\n  const [isImporting, setIsImporting] = useState(false)\n\n  const handleImport = async () => {\n    try {\n      setIsImporting(true)\n\n      // 选择 zip 文件\n      const filePath = await open({\n        title: t('selectSkillZip'),\n        filters: [{\n          name: 'ZIP Files',\n          extensions: ['zip']\n        }],\n        multiple: false\n      })\n\n      if (!filePath || Array.isArray(filePath)) {\n        setIsImporting(false)\n        return\n      }\n\n      // 调用后端命令导入 Skill\n      const skillName = await invoke<string>('import_skill_zip', { zipPath: filePath })\n\n      toast({\n        title: t('importSuccess'),\n        description: `${skillName} ${t('imported')}`,\n      })\n\n      // 刷新 Skills 列表\n      await refreshSkills()\n    } catch (error) {\n      console.error('Import skill failed:', error)\n      toast({\n        title: t('importError'),\n        description: (error as Error).message,\n        variant: 'destructive',\n      })\n    } finally {\n      setIsImporting(false)\n    }\n  }\n\n  return (\n    <div className=\"global-skills-manager\">\n      {/* 操作栏 */}\n      <div className=\"flex max-md:flex-col max-md:items-start max-md:gap-4 justify-between items-center mb-4\">\n        <div>\n          <h3 className=\"text-lg font-semibold\">\n            {t('installedGlobalSkills')} ({globalSkills.length})\n          </h3>\n          {/* 导入说明 */}\n          <div className=\"flex items-center gap-2 mt-2 text-sm text-muted-foreground\">\n            <Info className=\"size-4\" />\n            <p>{t('importHelp')}</p>\n          </div>\n        </div>\n        <Button variant=\"outline\" size=\"sm\" onClick={handleImport} disabled={isImporting}>\n          {isImporting ? (\n            <Loader2 className=\"size-4 animate-spin\" />\n          ) : (\n            <Upload className=\"size-4\" />\n          )}\n          {isImporting ? t('importing') : t('importSkill')}\n        </Button>\n      </div>\n\n      {/* Skills 列表 */}\n      <div className=\"space-y-2\">\n        {globalSkills.map((skill) => (\n          <SkillCard\n            key={skill.id}\n            skill={skill}\n            onRefresh={refreshSkills}\n          />\n        ))}\n\n        {globalSkills.length === 0 && (\n          <div className=\"text-center py-12 text-muted-foreground\">\n            <Sparkles className=\"mx-auto h-12 w-12 mb-4 opacity-50\" />\n            <p>{t('noSkillsGlobal')}</p>\n            <p className=\"text-sm\">{t('noSkillsGlobalDesc')}</p>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/skills/components/project-skills-list.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Sparkles } from 'lucide-react'\nimport { useSkillsStore } from '@/stores/skills'\nimport { SkillCard } from './skill-card'\n\nexport function ProjectSkillsList() {\n  const t = useTranslations('settings.skills')\n  const { projectSkills, refreshSkills } = useSkillsStore()\n\n  const handleRefresh = async () => {\n    await refreshSkills()\n  }\n\n  return (\n    <div className=\"project-skills-list\">\n      {/* 操作栏 */}\n      <div className=\"flex justify-between items-center mb-4\">\n        <h3 className=\"text-lg font-semibold\">\n          {t('project')} ({projectSkills.length})\n        </h3>\n        <Button variant=\"outline\" size=\"sm\" onClick={handleRefresh}>\n          刷新\n        </Button>\n      </div>\n\n      {/* Skills 列表 */}\n      <div className=\"space-y-2\">\n        {projectSkills.map((skill) => (\n          <SkillCard\n            key={skill.id}\n            skill={skill}\n            onRefresh={handleRefresh}\n          />\n        ))}\n\n        {projectSkills.length === 0 && (\n          <div className=\"text-center py-12 text-muted-foreground\">\n            <Sparkles className=\"mx-auto h-12 w-12 mb-4 opacity-50\" />\n            <p>{t('emptyWorkspace')}</p>\n            <p className=\"text-sm\">{t('emptyWorkspaceDesc')}</p>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/skills/components/skill-card.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Sparkles, Trash, Loader2, Edit2 } from 'lucide-react'\nimport { useSkillsStore } from '@/stores/skills'\nimport { Textarea } from '@/components/ui/textarea'\nimport { SkillMetadata } from '@/lib/skills/types'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\n\ninterface SkillCardProps {\n  skill: SkillMetadata\n  onRefresh: () => void\n}\n\nexport function SkillCard({ skill, onRefresh }: SkillCardProps) {\n  const t = useTranslations('settings.skills')\n  const tc = useTranslations('common')\n  const { getSkill, updateSkillInstructions, deleteSkill } = useSkillsStore()\n\n  const [instructions, setInstructions] = useState('')\n  const [isSaving, setIsSaving] = useState(false)\n  const [hasChanges, setHasChanges] = useState(false)\n  const [isEditing, setIsEditing] = useState(false)\n  const saveTimeoutRef = useRef<NodeJS.Timeout>()\n\n  const skillContent = getSkill(skill.id)\n\n  // 初始化指令内容\n  useEffect(() => {\n    if (skillContent) {\n      setInstructions(skillContent.instructions)\n    }\n  }, [skillContent])\n\n  // 自动保存\n  useEffect(() => {\n    if (hasChanges && isEditing) {\n      // 清除之前的定时器\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current)\n      }\n\n      // 设置新的定时器，1秒后保存\n      saveTimeoutRef.current = setTimeout(async () => {\n        await handleSave()\n      }, 1000)\n\n      return () => {\n        if (saveTimeoutRef.current) {\n          clearTimeout(saveTimeoutRef.current)\n        }\n      }\n    }\n  }, [instructions, hasChanges, isEditing])\n\n  const handleDelete = async () => {\n    try {\n      await deleteSkill(skill.id)\n      onRefresh()\n    } catch (error) {\n      console.error('Failed to delete skill:', error)\n    }\n  }\n\n  const handleSave = async () => {\n    if (!hasChanges) return\n\n    try {\n      setIsSaving(true)\n      await updateSkillInstructions(skill.id, instructions)\n      setHasChanges(false)\n    } catch (error) {\n      console.error('Failed to save instructions:', error)\n    } finally {\n      setIsSaving(false)\n    }\n  }\n\n  const handleInstructionsChange = (value: string) => {\n    setInstructions(value)\n    setHasChanges(true)\n  }\n\n  const handleToggleEdit = () => {\n    setIsEditing(!isEditing)\n  }\n\n  return (\n    <Card className=\"w-full\">\n      <CardHeader className=\"pb-3\">\n        <div className=\"flex items-start justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <Sparkles className=\"size-5 text-primary\" />\n            <CardTitle className=\"text-lg\">{skill.name}</CardTitle>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"text-muted-foreground\"\n              onClick={handleToggleEdit}\n            >\n              <Edit2 className=\"size-4\" />\n            </Button>\n            <AlertDialog>\n              <AlertDialogTrigger asChild>\n                <Button variant=\"ghost\" size=\"sm\" className=\"text-muted-foreground hover:text-destructive\">\n                  <Trash className=\"size-4\" />\n                </Button>\n              </AlertDialogTrigger>\n              <AlertDialogContent>\n                <AlertDialogHeader>\n                  <AlertDialogTitle>{t('deleteSkillTitle')}</AlertDialogTitle>\n                  <AlertDialogDescription>\n                    {t('deleteSkillDesc')}\n                  </AlertDialogDescription>\n                </AlertDialogHeader>\n                <AlertDialogFooter>\n                  <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>\n                  <AlertDialogAction onClick={handleDelete}>\n                    {tc('delete')}\n                  </AlertDialogAction>\n                </AlertDialogFooter>\n              </AlertDialogContent>\n            </AlertDialog>\n          </div>\n        </div>\n        <p className=\"text-sm text-muted-foreground mt-2 truncate\">\n          {skill.description}\n        </p>\n      </CardHeader>\n      <CardContent>\n        {/* 指令编辑器 - 只在编辑模式下显示 */}\n        {skillContent && isEditing && (\n          <div className=\"mt-4 space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <p className=\"text-xs text-muted-foreground\">\n                {t('instructions')}:\n              </p>\n              <div className=\"flex items-center gap-2\">\n                {hasChanges && (\n                  <span className=\"text-xs text-muted-foreground\">\n                    {tc('unsaved')}\n                  </span>\n                )}\n                {isSaving && (\n                  <div className=\"flex items-center gap-1\">\n                    <Loader2 className=\"size-3 animate-spin\" />\n                    <span className=\"text-xs text-muted-foreground\">\n                      {tc('saving')}\n                    </span>\n                  </div>\n                )}\n              </div>\n            </div>\n            <Textarea\n              value={instructions}\n              onChange={(e) => handleInstructionsChange(e.target.value)}\n              className=\"min-h-40 max-h-96 font-mono text-sm resize-y\"\n              placeholder={t('instructionsPlaceholder')}\n            />\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/skills/components/skills-settings.tsx",
    "content": "'use client'\n\nimport { GlobalSkillsManager } from './global-skills-manager'\n\nexport function SkillsSettings() {\n\n  return (\n    <div className=\"skills-settings\">\n      <GlobalSkillsManager />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/skills/page.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Sparkles } from 'lucide-react'\nimport { SettingType } from '../components/setting-base'\nimport { useSkillsStore } from '@/stores/skills'\nimport { SkillsSettings } from './components/skills-settings'\n\nexport default function SkillsSettingPage() {\n  const t = useTranslations('settings.skills')\n  const { initSkills } = useSkillsStore()\n\n  useEffect(() => {\n    initSkills()\n  }, [initSkills])\n\n  return (\n    <SettingType\n      id=\"skills\"\n      title={t('title')}\n      desc={t('desc')}\n      icon={<Sparkles />}\n    >\n      <SkillsSettings />\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/components/sync-platform-card.tsx",
    "content": "'use client'\n\nimport { Input } from \"@/components/ui/input\"\nimport { Button } from \"@/components/ui/button\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { useEffect, useState, useCallback } from \"react\"\nimport { useTranslations } from 'next-intl'\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { SyncStateEnum } from \"@/lib/sync/github.types\"\nimport { SyncPlatform } from \"@/types/sync\"\nimport { Eye, EyeOff, RefreshCcw, Loader2, AlertCircle, CheckCircle2, XCircle } from \"lucide-react\"\nimport { OpenBroswer } from \"@/components/open-broswer\"\n\nexport interface SyncPlatformConfig {\n  platform: SyncPlatform\n  tokenKey: string\n  tokenLabel: string\n  tokenDesc: string\n  tokenUrl: string\n  tokenUrlText: string\n}\n\ninterface SyncPlatformCardProps {\n  config: SyncPlatformConfig\n  accessToken: string\n  setAccessToken: (token: string) => void\n  syncRepoState: SyncStateEnum\n  syncRepoInfo?: any\n  customRepo: string\n  setCustomRepo: (repo: string) => void\n  defaultRepoName: string\n  onCheckRepo: () => void\n  onCreateRepo: () => void\n  children?: React.ReactNode\n}\n\nexport function SyncPlatformCard({\n  config,\n  accessToken,\n  setAccessToken,\n  syncRepoState,\n  syncRepoInfo,\n  customRepo,\n  setCustomRepo,\n  defaultRepoName,\n  onCheckRepo,\n  onCreateRepo,\n  children,\n}: SyncPlatformCardProps) {\n  const t = useTranslations()\n  const [tokenVisible, setTokenVisible] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [isInitializing, setIsInitializing] = useState(true)\n\n  // 初始化加载 token\n  useEffect(() => {\n    const init = async () => {\n      try {\n        const store = await Store.load('store.json')\n        const token = await store.get<string>(config.tokenKey)\n        if (token) {\n          setAccessToken(token)\n        }\n      } catch (err) {\n        console.error(`Failed to load ${config.platform} token:`, err)\n      } finally {\n        setIsInitializing(false)\n      }\n    }\n    init()\n  }, [config.tokenKey, setAccessToken])\n\n  // 监听 syncRepoState 变化来显示错误\n  useEffect(() => {\n    if (syncRepoState === SyncStateEnum.fail && accessToken) {\n      // 可以在这里设置错误消息，但通常由具体组件设置\n    }\n  }, [syncRepoState, accessToken])\n\n  const handleTokenChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {\n    const value = e.target.value\n    setAccessToken(value)\n    setError(null)\n\n    try {\n      const store = await Store.load('store.json')\n      await store.set(config.tokenKey, value)\n      await store.save()\n    } catch (err) {\n      console.error('Failed to save token:', err)\n    }\n  }, [config.tokenKey, setAccessToken])\n\n  const isLoading = syncRepoState === SyncStateEnum.checking || syncRepoState === SyncStateEnum.creating\n\n  return (\n    <div className=\"rounded-md border p-4\">\n      <div className=\"flex justify-between items-center mb-2\">\n        <div className=\"flex gap-2 items-center\">\n          <span className=\"font-semibold\">\n            {config.platform.charAt(0).toUpperCase() + config.platform.slice(1)} {t('settings.sync.settings')}\n          </span>\n        </div>\n        <StatusBadge state={syncRepoState} />\n      </div>\n      <p className=\"text-sm text-muted-foreground mb-4\">{t('settings.sync.platformDesc')}</p>\n\n      {/* Token 输入 */}\n      <div className=\"space-y-2\">\n        <label className=\"text-sm font-medium\">{config.tokenLabel}</label>\n        <div className=\"flex gap-2\">\n          <Input\n            value={accessToken}\n            onChange={handleTokenChange}\n            type={tokenVisible ? 'text' : 'password'}\n            placeholder={t('settings.sync.enterToken')}\n            disabled={isInitializing}\n          />\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            onClick={() => setTokenVisible(!tokenVisible)}\n          >\n            {tokenVisible ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n          </Button>\n        </div>\n        <OpenBroswer\n          url={config.tokenUrl}\n          title={t('settings.sync.newToken')}\n          className=\"text-sm text-blue-500 hover:underline\"\n        />\n      </div>\n\n      {/* 自定义仓库 */}\n      <div className=\"mt-4 space-y-2\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.customSyncRepo')}</label>\n        <Input\n          value={customRepo}\n          onChange={(e) => setCustomRepo(e.target.value)}\n          placeholder={defaultRepoName}\n        />\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.customSyncRepoDesc')}</p>\n      </div>\n\n      {/* 操作按钮 */}\n      <div className=\"mt-4 flex gap-2 flex-wrap\">\n        {accessToken ? (\n          <>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={onCheckRepo}\n              disabled={isLoading}\n            >\n              {isLoading ? (\n                <>\n                  <Loader2 className=\"size-4 mr-1 animate-spin\" />\n                  {syncRepoState === SyncStateEnum.checking\n                    ? t('settings.sync.checking')\n                    : t('settings.sync.creating')}\n                </>\n              ) : (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1\" />\n                  {t('settings.sync.checkRepo')}\n                </>\n              )}\n            </Button>\n            {syncRepoState === SyncStateEnum.fail && (\n              <Button variant=\"outline\" size=\"sm\" onClick={onCreateRepo} disabled={isLoading}>\n                <Loader2 className={`size-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />\n                {t('settings.sync.createRepo')}\n              </Button>\n            )}\n          </>\n        ) : (\n          <div className=\"flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400\">\n            <AlertCircle className=\"size-4\" />\n            {t('settings.sync.enterTokenHint')}\n          </div>\n        )}\n      </div>\n\n      {/* 错误提示 */}\n      {error && (\n        <div className=\"mt-3 flex items-center gap-2 text-sm text-red-500\">\n          <AlertCircle className=\"size-4\" />\n          {error}\n        </div>\n      )}\n\n      {/* 仓库信息 */}\n      {syncRepoInfo && (\n        <div className=\"border-t mt-4 pt-4\">\n          {children}\n        </div>\n      )}\n    </div>\n  )\n}\n\n// 状态徽章组件\nfunction StatusBadge({ state }: { state: SyncStateEnum }) {\n  if (state === SyncStateEnum.success) {\n    return (\n      <Badge className=\"bg-green-600\">\n        <CheckCircle2 className=\"size-3 mr-1\" />\n        Connected\n      </Badge>\n    )\n  }\n\n  if (state === SyncStateEnum.checking || state === SyncStateEnum.creating) {\n    return (\n      <Badge className=\"bg-blue-600\">\n        <Loader2 className=\"size-3 mr-1 animate-spin\" />\n        {state === SyncStateEnum.checking ? 'Checking' : 'Creating'}\n      </Badge>\n    )\n  }\n\n  if (state === SyncStateEnum.fail) {\n    return (\n      <Badge className=\"bg-zinc-500\">\n        <XCircle className=\"size-3 mr-1\" />\n        Not Connected\n      </Badge>\n    )\n  }\n\n  return null\n}\n\nexport { StatusBadge }\n"
  },
  {
    "path": "src/app/core/setting/sync/gitea-sync.tsx",
    "content": "'use client'\nimport { Input } from \"@/components/ui/input\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslations } from 'next-intl';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useSyncStore from \"@/stores/sync\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport dayjs from \"dayjs\";\nimport relativeTime from \"dayjs/plugin/relativeTime\";\nimport { Button } from \"@/components/ui/button\";\nimport { checkSyncRepoState, createSyncRepo, getUserInfo } from \"@/lib/sync/gitea\";\nimport { RepoNames, SyncStateEnum } from \"@/lib/sync/github.types\";\nimport { GiteaInstanceType, GITEA_INSTANCES } from \"@/lib/sync/gitea.types\";\nimport { Eye, EyeOff, Globe, Server, Plus, RefreshCcw } from \"lucide-react\";\nimport { Avatar, AvatarImage } from \"@/components/ui/avatar\";\n\ndayjs.extend(relativeTime)\n\nexport function GiteaSync() {\n  const t = useTranslations();\n  const {\n    giteaInstanceType,\n    setGiteaInstanceType,\n    giteaCustomUrl,\n    setGiteaCustomUrl,\n    giteaAccessToken,\n    setGiteaAccessToken,\n    giteaCustomSyncRepo,\n    setGiteaCustomSyncRepo\n  } = useSettingStore()\n  \n  const {\n    giteaUserInfo,\n    setGiteaUserInfo,\n    giteaSyncRepoState,\n    setGiteaSyncRepoState,\n    giteaSyncRepoInfo,\n    setGiteaSyncRepoInfo\n  } = useSyncStore()\n\n  const [giteaAccessTokenVisible, setGiteaAccessTokenVisible] = useState<boolean>(false)\n\n  // 获取实际使用的仓库名称\n  const getRepoName = () => {\n    return giteaCustomSyncRepo.trim() || RepoNames.sync\n  }\n\n\n  // 检查 Gitea 仓库状态（仅检查，不创建）\n  async function checkRepoState() {\n    try {\n      setGiteaSyncRepoState(SyncStateEnum.checking)\n      // 先清空之前的仓库信息\n      setGiteaSyncRepoInfo(undefined)\n      \n      // 获取并保存用户信息\n      const userInfo = await getUserInfo();\n      setGiteaUserInfo(userInfo);\n      \n      // 检查同步仓库状态\n      const repoName = getRepoName()\n      const syncRepo = await checkSyncRepoState(repoName)\n      \n      if (syncRepo) {\n        setGiteaSyncRepoInfo(syncRepo)\n        setGiteaSyncRepoState(SyncStateEnum.success)\n      } else {\n        setGiteaSyncRepoInfo(undefined)\n        setGiteaSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check Gitea repos:', err)\n      setGiteaSyncRepoInfo(undefined)\n      setGiteaSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  // 手动创建仓库\n  async function createGiteaRepo() {\n    try {\n      setGiteaSyncRepoState(SyncStateEnum.creating)\n      const repoName = getRepoName()\n      const info = await createSyncRepo(repoName, true)\n      if (info) {\n        setGiteaSyncRepoInfo(info)\n        setGiteaSyncRepoState(SyncStateEnum.success)\n      } else {\n        setGiteaSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to create Gitea repo:', err)\n      setGiteaSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  // Token 变化处理\n  async function tokenChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    if (value === '') {\n      setGiteaSyncRepoState(SyncStateEnum.fail)\n      setGiteaSyncRepoInfo(undefined)\n      setGiteaUserInfo(undefined)\n    }\n    setGiteaAccessToken(value)\n    const store = await Store.load('store.json');\n    await store.set('giteaAccessToken', value)\n    await store.save()\n    \n    // 如果 token 有效，自动检查仓库状态\n    if (value.trim()) {\n      // 等待一下再检查，避免频繁请求\n      setTimeout(() => {\n        checkRepoState()\n      }, 500)\n    }\n  }\n\n  // 实例类型变化处理\n  async function instanceTypeChangeHandler(value: GiteaInstanceType) {\n    await setGiteaInstanceType(value)\n    // 如果有 token，重新检查仓库状态\n    if (giteaAccessToken.trim()) {\n      setTimeout(() => {\n        checkRepoState()\n      }, 500)\n    }\n  }\n\n  // 自定义 URL 变化处理\n  async function customUrlChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    let value = e.target.value\n    // 自动移除末尾的斜杠\n    value = value.replace(/\\/+$/, '')\n    await setGiteaCustomUrl(value)\n    // 如果是自建实例且有 token，重新检查仓库状态\n    if (giteaInstanceType === GiteaInstanceType.SELF_HOSTED && giteaAccessToken.trim() && value.trim()) {\n      setTimeout(() => {\n        checkRepoState()\n      }, 500)\n    }\n  }\n\n  // 获取当前实例的 Token 创建 URL\n  function getTokenCreateUrl() {\n    if (giteaInstanceType === GiteaInstanceType.SELF_HOSTED) {\n      return giteaCustomUrl ? `${giteaCustomUrl}/user/settings/applications` : '#'\n    }\n    const instance = GITEA_INSTANCES[giteaInstanceType]\n    return `${instance.baseUrl}/user/settings/applications`\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      \n      // 加载实例类型\n      const instanceType = await store.get<GiteaInstanceType>('giteaInstanceType')\n      if (instanceType) {\n        setGiteaInstanceType(instanceType)\n      }\n      \n      // 加载自定义 URL\n      const customUrl = await store.get<string>('giteaCustomUrl')\n      if (customUrl) {\n        setGiteaCustomUrl(customUrl)\n      }\n      \n      // 加载访问令牌\n      const token = await store.get<string>('giteaAccessToken')\n      if (token) {\n        setGiteaAccessToken(token)\n        // 如果有 token，自动检查仓库状态\n        checkRepoState()\n      } else {\n        setGiteaAccessToken('')\n      }\n    }\n    init()\n  }, [])\n\n\n\n  return (\n    <div className=\"rounded-md border p-4\">\n      <div className=\"flex justify-between items-center mb-2\">\n        <div className=\"flex gap-2 items-center\">\n          <span className=\"font-semibold\">Gitea {t('settings.sync.settings')}</span>\n        </div>\n        <Badge className={`${giteaSyncRepoState === SyncStateEnum.success ? 'bg-green-600' : 'bg-zinc-500'}`}>\n          {giteaSyncRepoState === SyncStateEnum.success ? 'Connected' : giteaSyncRepoState === SyncStateEnum.checking ? 'Checking' : giteaSyncRepoState === SyncStateEnum.creating ? 'Creating' : 'Not Connected'}\n        </Badge>\n      </div>\n      <p className=\"text-sm text-muted-foreground mb-4\">{t('settings.sync.platformDesc')}</p>\n\n      {/* 实例类型选择 */}\n      <div className=\"space-y-2 mb-4\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.giteaInstanceType')}</label>\n        <Select value={giteaInstanceType} onValueChange={instanceTypeChangeHandler}>\n          <SelectTrigger className=\"w-full\">\n            <SelectValue placeholder={t('settings.sync.giteaInstanceTypePlaceholder')} />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value={GiteaInstanceType.OFFICIAL}>\n              <div className=\"flex items-center gap-2\">\n                <Globe className=\"size-4\" />\n                <div>\n                  <div className=\"font-medium\">Gitea.com</div>\n                </div>\n              </div>\n            </SelectItem>\n            <SelectItem value={GiteaInstanceType.SELF_HOSTED}>\n              <div className=\"flex items-center gap-2\">\n                <Server className=\"size-4\" />\n                <div>\n                  <div className=\"font-medium\">{t('settings.sync.giteaInstanceTypeOptions.selfHosted')}</div>\n                </div>\n              </div>\n            </SelectItem>\n          </SelectContent>\n        </Select>\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.giteaInstanceTypeDesc')}</p>\n      </div>\n\n      {/* 自定义 URL（自建实例时显示） */}\n      {giteaInstanceType === GiteaInstanceType.SELF_HOSTED && (\n        <div className=\"space-y-2 mb-4\">\n          <label className=\"text-sm font-medium\">Gitea URL</label>\n          <Input\n            value={giteaCustomUrl}\n            onChange={customUrlChangeHandler}\n            placeholder=\"https://gitea.example.com\"\n            type=\"url\"\n          />\n          <p className=\"text-xs text-muted-foreground\">{t('settings.sync.giteaInstanceTypeOptions.selfHostedDesc')}</p>\n        </div>\n      )}\n\n      {/* Token 输入 */}\n      <div className=\"space-y-2\">\n        <label className=\"text-sm font-medium\">Gitea Access Token</label>\n        <div className=\"flex gap-2\">\n          <Input\n            value={giteaAccessToken}\n            onChange={tokenChangeHandler}\n            type={giteaAccessTokenVisible ? 'text' : 'password'}\n            placeholder={t('settings.sync.enterToken')}\n          />\n          <Button variant=\"outline\" size=\"icon\" onClick={() => setGiteaAccessTokenVisible(!giteaAccessTokenVisible)}>\n            {giteaAccessTokenVisible ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n          </Button>\n        </div>\n        <OpenBroswer\n          url={getTokenCreateUrl()}\n          title={t('settings.sync.newToken')}\n          className=\"text-sm text-blue-500 hover:underline\"\n        />\n      </div>\n\n      {/* 自定义仓库 */}\n      <div className=\"mt-4 space-y-2\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.customSyncRepo')}</label>\n        <Input\n          value={giteaCustomSyncRepo}\n          onChange={(e) => setGiteaCustomSyncRepo(e.target.value)}\n          placeholder={RepoNames.sync}\n        />\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.customSyncRepoDesc')}</p>\n      </div>\n\n      {/* 操作按钮 */}\n      <div className=\"mt-4 flex gap-2 flex-wrap\">\n        {giteaAccessToken ? (\n          <>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={checkRepoState}\n              disabled={giteaSyncRepoState === SyncStateEnum.checking || giteaSyncRepoState === SyncStateEnum.creating}\n            >\n              {giteaSyncRepoState === SyncStateEnum.checking || giteaSyncRepoState === SyncStateEnum.creating ? (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1 animate-spin\" />\n                  {giteaSyncRepoState === SyncStateEnum.checking ? t('settings.sync.checking') : t('settings.sync.creating')}\n                </>\n              ) : (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1\" />\n                  {t('settings.sync.checkRepo')}\n                </>\n              )}\n            </Button>\n            {giteaSyncRepoState === SyncStateEnum.fail && (\n              <Button variant=\"outline\" size=\"sm\" onClick={createGiteaRepo}>\n                <Plus className=\"size-4 mr-1\" />\n                {t('settings.sync.createRepo')}\n              </Button>\n            )}\n          </>\n        ) : (\n          <div className=\"flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400\">\n            <RefreshCcw className=\"size-4\" />\n            {t('settings.sync.enterTokenHint')}\n          </div>\n        )}\n      </div>\n\n      {/* 仓库信息 */}\n      {giteaSyncRepoInfo && (\n        <div className=\"border-t mt-4 pt-4\">\n          <div className=\"flex items-center gap-4\">\n            <Avatar className=\"size-10\">\n              <AvatarImage src={giteaUserInfo?.avatar_url || ''} />\n            </Avatar>\n            <div>\n              <h3 className=\"text-xl font-bold mb-1\">\n                <OpenBroswer title={giteaSyncRepoInfo?.full_name || ''} url={giteaSyncRepoInfo?.html_url || ''} />\n              </h3>\n              <p className=\"text-sm text-zinc-500\">\n                {giteaSyncRepoInfo?.private ? t('settings.sync.private') : t('settings.sync.public')} · {t('settings.sync.createdAt', { time: dayjs(giteaSyncRepoInfo?.created_at).fromNow() })} · {t('settings.sync.updatedAt', { time: dayjs(giteaSyncRepoInfo?.updated_at).fromNow() })}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/gitee-sync.tsx",
    "content": "'use client'\nimport { Input } from \"@/components/ui/input\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslations } from 'next-intl';\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useSyncStore from \"@/stores/sync\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport dayjs from \"dayjs\";\nimport relativeTime from \"dayjs/plugin/relativeTime\";\nimport { checkSyncRepoState, createSyncRepo, getUserInfo } from \"@/lib/sync/gitee\";\nimport { Button } from \"@/components/ui/button\";\nimport { RepoNames, SyncStateEnum } from \"@/lib/sync/github.types\";\nimport { Eye, EyeOff, Plus, RefreshCcw } from \"lucide-react\";\nimport { Avatar, AvatarImage } from \"@/components/ui/avatar\";\n\ndayjs.extend(relativeTime)\n\nexport function GiteeSync() {\n  const t = useTranslations();\n  const {\n    giteeAccessToken,\n    setGiteeAccessToken,\n    giteeCustomSyncRepo,\n    setGiteeCustomSyncRepo\n  } = useSettingStore()\n  \n  const {\n    giteeSyncRepoState,\n    setGiteeSyncRepoState,\n    giteeSyncRepoInfo,\n    setGiteeSyncRepoInfo\n  } = useSyncStore()\n\n  const [giteeAccessTokenVisible, setGiteeAccessTokenVisible] = useState<boolean>(false)\n\n  // 获取实际使用的仓库名称\n  const getRepoName = () => {\n    return giteeCustomSyncRepo.trim() || RepoNames.sync\n  }\n\n\n  // 检查 Gitee 仓库状态（仅检查，不创建）\n  async function checkRepoState() {\n    try {\n      setGiteeSyncRepoState(SyncStateEnum.checking)\n      // 先清空之前的仓库信息\n      setGiteeSyncRepoInfo(undefined)\n      \n      // 添加超时保护，避免无限等待\n      const timeoutPromise = new Promise((_, reject) => {\n        setTimeout(() => reject(new Error('检测超时')), 15000) // 15秒超时\n      })\n      \n      // 使用 Promise.race 来处理超时\n      await Promise.race([\n        (async () => {\n          // 先检查网络连接\n          if (!navigator.onLine) {\n            throw new Error('网络连接不可用')\n          }\n          \n          await getUserInfo();\n          const repoName = getRepoName()\n          const syncRepo = await checkSyncRepoState(repoName)\n          \n          if (syncRepo) {\n            setGiteeSyncRepoInfo(syncRepo)\n            setGiteeSyncRepoState(SyncStateEnum.success)\n          } else {\n            setGiteeSyncRepoInfo(undefined)\n            setGiteeSyncRepoState(SyncStateEnum.fail)\n          }\n        })(),\n        timeoutPromise\n      ])\n      \n    } catch (err) {\n      console.error('Failed to check Gitee repos:', err)\n      setGiteeSyncRepoInfo(undefined)\n      setGiteeSyncRepoState(SyncStateEnum.fail)\n      \n      // 如果是超时错误，显示特定提示\n      if (err instanceof Error) {\n        if (err.message === '检测超时') {\n          console.warn('Gitee 仓库检测超时，可能是网络问题')\n        } else if (err.message === '网络连接不可用') {\n          console.warn('网络连接不可用，请检查网络设置')\n        }\n      }\n    }\n  }\n\n  // 手动创建仓库\n  async function createGiteeRepo() {\n    try {\n      setGiteeSyncRepoState(SyncStateEnum.creating)\n      const repoName = getRepoName()\n      \n      // 添加超时保护\n      const timeoutPromise = new Promise((_, reject) => {\n        setTimeout(() => reject(new Error('创建超时')), 20000) // 20秒超时\n      })\n      \n      await Promise.race([\n        (async () => {\n          const info = await createSyncRepo(repoName, true)\n          if (info) {\n            setGiteeSyncRepoInfo(info)\n            setGiteeSyncRepoState(SyncStateEnum.success)\n          } else {\n            setGiteeSyncRepoState(SyncStateEnum.fail)\n          }\n        })(),\n        timeoutPromise\n      ])\n      \n    } catch (err) {\n      console.error('Failed to create Gitee repo:', err)\n      setGiteeSyncRepoState(SyncStateEnum.fail)\n      \n      if (err instanceof Error && err.message === '创建超时') {\n        console.warn('Gitee 仓库创建超时，可能是网络问题')\n      }\n    }\n  }\n\n  async function tokenChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    if (value === '') {\n      setGiteeSyncRepoState(SyncStateEnum.fail)\n      setGiteeSyncRepoInfo(undefined)\n    }\n    setGiteeAccessToken(value)\n    const store = await Store.load('store.json');\n    await store.set('giteeAccessToken', value)\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      const token = await store.get<string>('giteeAccessToken')\n      if (token) {\n        setGiteeAccessToken(token)\n      } else {\n        setGiteeAccessToken('')\n      }\n    }\n    init()\n\n    // 添加网络状态监听\n    const handleOnline = () => {\n      // Network connected\n    }\n\n    const handleOffline = () => {\n      // Network disconnected\n      setGiteeSyncRepoState(SyncStateEnum.fail)\n      setGiteeSyncRepoInfo(undefined)\n    }\n\n    window.addEventListener('online', handleOnline)\n    window.addEventListener('offline', handleOffline)\n\n    return () => {\n      window.removeEventListener('online', handleOnline)\n      window.removeEventListener('offline', handleOffline)\n    }\n  }, [])\n\n\n  return (\n    <div className=\"rounded-md border p-4\">\n      <div className=\"flex justify-between items-center mb-2\">\n        <div className=\"flex gap-2 items-center\">\n          <span className=\"font-semibold\">Gitee {t('settings.sync.settings')}</span>\n        </div>\n        <Badge className={`${giteeSyncRepoState === SyncStateEnum.success ? 'bg-green-600' : 'bg-zinc-500'}`}>\n          {giteeSyncRepoState === SyncStateEnum.success ? 'Connected' : giteeSyncRepoState === SyncStateEnum.checking ? 'Checking' : giteeSyncRepoState === SyncStateEnum.creating ? 'Creating' : 'Not Connected'}\n        </Badge>\n      </div>\n      <p className=\"text-sm text-muted-foreground mb-4\">{t('settings.sync.platformDesc')}</p>\n\n      {/* Token 输入 */}\n      <div className=\"space-y-2\">\n        <label className=\"text-sm font-medium\">Gitee 私人令牌</label>\n        <div className=\"flex gap-2\">\n          <Input value={giteeAccessToken} onChange={tokenChangeHandler} type={giteeAccessTokenVisible ? 'text' : 'password'} placeholder={t('settings.sync.enterToken')} />\n          <Button variant=\"outline\" size=\"icon\" onClick={() => setGiteeAccessTokenVisible(!giteeAccessTokenVisible)}>\n            {giteeAccessTokenVisible ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n          </Button>\n        </div>\n        <OpenBroswer url=\"https://gitee.com/profile/personal_access_tokens/new\" title={t('settings.sync.newToken')} className=\"text-sm text-blue-500 hover:underline\" />\n      </div>\n\n      {/* 自定义仓库 */}\n      <div className=\"mt-4 space-y-2\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.customSyncRepo')}</label>\n        <Input\n          value={giteeCustomSyncRepo}\n          onChange={(e) => setGiteeCustomSyncRepo(e.target.value)}\n          placeholder={RepoNames.sync}\n        />\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.customSyncRepoDesc')}</p>\n      </div>\n\n      {/* 操作按钮 */}\n      <div className=\"mt-4 flex gap-2 flex-wrap\">\n        {giteeAccessToken ? (\n          <>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={checkRepoState}\n              disabled={giteeSyncRepoState === SyncStateEnum.checking || giteeSyncRepoState === SyncStateEnum.creating}\n            >\n              {giteeSyncRepoState === SyncStateEnum.checking || giteeSyncRepoState === SyncStateEnum.creating ? (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1 animate-spin\" />\n                  {giteeSyncRepoState === SyncStateEnum.checking ? t('settings.sync.checking') : t('settings.sync.creating')}\n                </>\n              ) : (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1\" />\n                  {t('settings.sync.checkRepo')}\n                </>\n              )}\n            </Button>\n            {giteeSyncRepoState === SyncStateEnum.fail && (\n              <Button variant=\"outline\" size=\"sm\" onClick={createGiteeRepo}>\n                <Plus className=\"size-4 mr-1\" />\n                {t('settings.sync.createRepo')}\n              </Button>\n            )}\n          </>\n        ) : (\n          <div className=\"flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400\">\n            <RefreshCcw className=\"size-4\" />\n            {t('settings.sync.enterTokenHint')}\n          </div>\n        )}\n      </div>\n\n      {/* 仓库信息 */}\n      {giteeSyncRepoInfo && (\n        <div className=\"border-t mt-4 pt-4\">\n          <div className=\"flex items-center gap-4\">\n            <Avatar className=\"size-10\">\n              <AvatarImage src={giteeSyncRepoInfo?.owner?.avatar_url || ''} />\n            </Avatar>\n            <div>\n              <h3 className=\"text-xl font-bold mb-1\">\n                <OpenBroswer title={giteeSyncRepoInfo?.full_name || ''} url={giteeSyncRepoInfo?.html_url || ''} />\n              </h3>\n              <p className=\"text-sm text-zinc-500\">\n                {giteeSyncRepoInfo?.private ? t('settings.sync.private') : t('settings.sync.public')} · {t('settings.sync.createdAt', { time: dayjs(giteeSyncRepoInfo?.created_at).fromNow() })} · {t('settings.sync.updatedAt', { time: dayjs(giteeSyncRepoInfo?.updated_at).fromNow() })}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/github-sync.tsx",
    "content": "'use client'\nimport { SyncPlatformCard } from \"./components/sync-platform-card\"\nimport { useTranslations } from 'next-intl'\nimport useSettingStore from \"@/stores/setting\"\nimport useSyncStore from \"@/stores/sync\"\nimport { OpenBroswer } from \"@/components/open-broswer\"\nimport dayjs from \"dayjs\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\nimport { checkSyncRepoState, createSyncRepo, getUserInfo } from \"@/lib/sync/github\"\nimport { RepoNames, SyncStateEnum } from \"@/lib/sync/github.types\"\nimport { Avatar, AvatarImage } from \"@/components/ui/avatar\"\n\ndayjs.extend(relativeTime)\n\nconst GITHUB_CONFIG = {\n  platform: 'github' as const,\n  tokenKey: 'accessToken',\n  tokenLabel: 'Github Access Token',\n  tokenDesc: '',\n  tokenUrl: 'https://github.com/settings/tokens/new',\n  tokenUrlText: '',\n}\n\nexport function GithubSync() {\n  const t = useTranslations()\n  const {\n    accessToken,\n    setAccessToken,\n    githubCustomSyncRepo,\n    setGithubCustomSyncRepo\n  } = useSettingStore()\n  const {\n    syncRepoState,\n    setSyncRepoState,\n    syncRepoInfo,\n    setSyncRepoInfo\n  } = useSyncStore()\n\n  const getRepoName = () => githubCustomSyncRepo.trim() || RepoNames.sync\n\n  async function checkGithubRepos() {\n    try {\n      setSyncRepoState(SyncStateEnum.checking)\n      setSyncRepoInfo(undefined)\n\n      await getUserInfo()\n      const repoName = getRepoName()\n      const syncRepo = await checkSyncRepoState(repoName)\n\n      if (syncRepo) {\n        setSyncRepoInfo(syncRepo)\n        setSyncRepoState(SyncStateEnum.success)\n      } else {\n        setSyncRepoInfo(undefined)\n        setSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check GitHub repos:', err)\n      setSyncRepoInfo(undefined)\n      setSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  async function createGithubRepo() {\n    try {\n      setSyncRepoState(SyncStateEnum.creating)\n      const repoName = getRepoName()\n      const info = await createSyncRepo(repoName, true)\n      if (info) {\n        setSyncRepoInfo(info)\n        setSyncRepoState(SyncStateEnum.success)\n      } else {\n        setSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to create GitHub repo:', err)\n      setSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  return (\n    <>\n      <SyncPlatformCard\n        config={GITHUB_CONFIG}\n        accessToken={accessToken}\n        setAccessToken={setAccessToken}\n        syncRepoState={syncRepoState}\n        syncRepoInfo={syncRepoInfo}\n        customRepo={githubCustomSyncRepo}\n        setCustomRepo={setGithubCustomSyncRepo}\n        defaultRepoName={RepoNames.sync}\n        onCheckRepo={checkGithubRepos}\n        onCreateRepo={createGithubRepo}\n      >\n        {/* 自定义仓库信息展示 */}\n        <div className=\"flex items-center gap-4\">\n          <Avatar className=\"size-12\">\n            <AvatarImage src={syncRepoInfo?.owner.avatar_url || ''} />\n          </Avatar>\n          <div>\n            <h3 className=\"text-xl font-bold mb-1\">\n              <OpenBroswer title={syncRepoInfo?.full_name || ''} url={syncRepoInfo?.html_url || ''} />\n            </h3>\n            <p className=\"text-sm text-zinc-500\">\n              {t('settings.sync.createdAt', { time: dayjs(syncRepoInfo?.created_at).fromNow() })}，{t('settings.sync.updatedAt', { time: dayjs(syncRepoInfo?.updated_at).fromNow() })}\n            </p>\n          </div>\n        </div>\n      </SyncPlatformCard>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/gitlab-sync.tsx",
    "content": "'use client'\nimport { Input } from \"@/components/ui/input\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslations } from 'next-intl';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport useSettingStore from \"@/stores/setting\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport useSyncStore from \"@/stores/sync\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { OpenBroswer } from \"@/components/open-broswer\";\nimport dayjs from \"dayjs\";\nimport relativeTime from \"dayjs/plugin/relativeTime\";\nimport { Button } from \"@/components/ui/button\";\nimport { checkSyncProjectState, createSyncProject, getUserInfo } from \"@/lib/sync/gitlab\";\nimport { RepoNames, SyncStateEnum } from \"@/lib/sync/github.types\";\nimport { GitlabInstanceType, GITLAB_INSTANCES } from \"@/lib/sync/gitlab.types\";\nimport { Eye, EyeOff, Globe, Server, Plus, RefreshCcw } from \"lucide-react\";\nimport { Avatar, AvatarImage } from \"@/components/ui/avatar\";\n\ndayjs.extend(relativeTime)\n\nexport function GitlabSync() {\n  const t = useTranslations();\n  const {\n    gitlabInstanceType,\n    setGitlabInstanceType,\n    gitlabCustomUrl,\n    setGitlabCustomUrl,\n    gitlabAccessToken,\n    setGitlabAccessToken,\n    gitlabCustomSyncRepo,\n    setGitlabCustomSyncRepo\n  } = useSettingStore()\n  \n  const {\n    gitlabUserInfo,\n    gitlabSyncProjectState,\n    setGitlabSyncProjectState,\n    gitlabSyncProjectInfo,\n    setGitlabSyncProjectInfo\n  } = useSyncStore()\n\n  const [gitlabAccessTokenVisible, setGitlabAccessTokenVisible] = useState<boolean>(false)\n\n  // 获取实际使用的仓库名称\n  const getRepoName = () => {\n    return gitlabCustomSyncRepo.trim() || RepoNames.sync\n  }\n\n\n  // 检查 Gitlab 项目状态（仅检查，不创建）\n  async function checkProjectState() {\n    try {\n      setGitlabSyncProjectState(SyncStateEnum.checking)\n      // 先清空之前的项目信息\n      setGitlabSyncProjectInfo(undefined)\n      \n      await getUserInfo();\n      // 检查同步项目状态\n      const repoName = getRepoName()\n      const syncProject = await checkSyncProjectState(repoName)\n      \n      if (syncProject) {\n        setGitlabSyncProjectInfo(syncProject)\n        setGitlabSyncProjectState(SyncStateEnum.success)\n      } else {\n        setGitlabSyncProjectInfo(undefined)\n        setGitlabSyncProjectState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check GitLab projects:', err)\n      setGitlabSyncProjectInfo(undefined)\n      setGitlabSyncProjectState(SyncStateEnum.fail)\n    }\n  }\n\n  // 手动创建项目\n  async function createGitlabProject() {\n    try {\n      setGitlabSyncProjectState(SyncStateEnum.creating)\n      const repoName = getRepoName()\n      const info = await createSyncProject(repoName, true)\n      if (info) {\n        setGitlabSyncProjectInfo(info)\n        setGitlabSyncProjectState(SyncStateEnum.success)\n      } else {\n        setGitlabSyncProjectState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to create Gitlab project:', err)\n      setGitlabSyncProjectState(SyncStateEnum.fail)\n    }\n  }\n\n  // Token 变化处理\n  async function tokenChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    if (value === '') {\n      setGitlabSyncProjectState(SyncStateEnum.fail)\n      setGitlabSyncProjectInfo(undefined)\n    }\n    setGitlabAccessToken(value)\n    const store = await Store.load('store.json');\n    await store.set('gitlabAccessToken', value)\n    await store.save()\n  }\n\n  // 实例类型变化处理\n  async function instanceTypeChangeHandler(value: GitlabInstanceType) {\n    await setGitlabInstanceType(value)\n  }\n\n  // 自定义 URL 变化处理\n  async function customUrlChangeHandler(e: React.ChangeEvent<HTMLInputElement>) {\n    const value = e.target.value\n    await setGitlabCustomUrl(value)\n  }\n\n  // 获取当前实例的 Token 创建 URL\n  function getTokenCreateUrl() {\n    if (gitlabInstanceType === GitlabInstanceType.SELF_HOSTED) {\n      return gitlabCustomUrl ? `${gitlabCustomUrl}/-/user_settings/personal_access_tokens` : '#'\n    }\n    const instance = GITLAB_INSTANCES[gitlabInstanceType]\n    return `${instance.baseUrl}/-/user_settings/personal_access_tokens`\n  }\n\n  useEffect(() => {\n    async function init() {\n      const store = await Store.load('store.json');\n      \n      // 加载实例类型\n      const instanceType = await store.get<GitlabInstanceType>('gitlabInstanceType')\n      if (instanceType) {\n        setGitlabInstanceType(instanceType)\n      }\n      \n      // 加载自定义 URL\n      const customUrl = await store.get<string>('gitlabCustomUrl')\n      if (customUrl) {\n        setGitlabCustomUrl(customUrl)\n      }\n      \n      // 加载访问令牌\n      const token = await store.get<string>('gitlabAccessToken')\n      if (token) {\n        setGitlabAccessToken(token)\n      } else {\n        setGitlabAccessToken('')\n      }\n    }\n    init()\n  }, [])\n\n\n\n  return (\n    <div className=\"rounded-md border p-4\">\n      <div className=\"flex justify-between items-center mb-2\">\n        <div className=\"flex gap-2 items-center\">\n          <span className=\"font-semibold\">GitLab {t('settings.sync.settings')}</span>\n        </div>\n        <Badge className={`${gitlabSyncProjectState === SyncStateEnum.success ? 'bg-green-600' : 'bg-zinc-500'}`}>\n          {gitlabSyncProjectState === SyncStateEnum.success ? 'Connected' : gitlabSyncProjectState === SyncStateEnum.checking ? 'Checking' : gitlabSyncProjectState === SyncStateEnum.creating ? 'Creating' : 'Not Connected'}\n        </Badge>\n      </div>\n      <p className=\"text-sm text-muted-foreground mb-4\">{t('settings.sync.platformDesc')}</p>\n\n      {/* 实例类型选择 */}\n      <div className=\"space-y-2 mb-4\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.gitlabInstanceType')}</label>\n        <Select value={gitlabInstanceType} onValueChange={instanceTypeChangeHandler}>\n          <SelectTrigger className=\"w-full\">\n            <SelectValue placeholder={t('settings.sync.gitlabInstanceTypePlaceholder')} />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value={GitlabInstanceType.OFFICIAL}>\n              <div className=\"flex items-center gap-2\">\n                <Globe className=\"size-4\" />\n                <div>\n                  <div className=\"font-medium\">GitLab.com</div>\n                </div>\n              </div>\n            </SelectItem>\n            <SelectItem value={GitlabInstanceType.JIHULAB}>\n              <div className=\"flex items-center gap-2\">\n                <Globe className=\"size-4\" />\n                <div>\n                  <div className=\"font-medium\">极狐</div>\n                </div>\n              </div>\n            </SelectItem>\n            <SelectItem value={GitlabInstanceType.SELF_HOSTED}>\n              <div className=\"flex items-center gap-2\">\n                <Server className=\"size-4\" />\n                <div>\n                  <div className=\"font-medium\">{t('settings.sync.gitlabInstanceTypeOptions.selfHosted')}</div>\n                </div>\n              </div>\n            </SelectItem>\n          </SelectContent>\n        </Select>\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.gitlabInstanceTypeDesc')}</p>\n      </div>\n\n      {/* 自定义 URL（自建实例时显示） */}\n      {gitlabInstanceType === GitlabInstanceType.SELF_HOSTED && (\n        <div className=\"space-y-2 mb-4\">\n          <label className=\"text-sm font-medium\">GitLab URL</label>\n          <Input\n            value={gitlabCustomUrl}\n            onChange={customUrlChangeHandler}\n            placeholder=\"https://gitlab.example.com\"\n            type=\"url\"\n          />\n          <p className=\"text-xs text-muted-foreground\">{t('settings.sync.gitlabInstanceTypeOptions.selfHostedDesc')}</p>\n        </div>\n      )}\n\n      {/* Token 输入 */}\n      <div className=\"space-y-2\">\n        <label className=\"text-sm font-medium\">GitLab Access Token</label>\n        <div className=\"flex gap-2\">\n          <Input\n            value={gitlabAccessToken}\n            onChange={tokenChangeHandler}\n            type={gitlabAccessTokenVisible ? 'text' : 'password'}\n            placeholder={t('settings.sync.enterToken')}\n          />\n          <Button variant=\"outline\" size=\"icon\" onClick={() => setGitlabAccessTokenVisible(!gitlabAccessTokenVisible)}>\n            {gitlabAccessTokenVisible ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n          </Button>\n        </div>\n        <OpenBroswer\n          url={getTokenCreateUrl()}\n          title={t('settings.sync.newToken')}\n          className=\"text-sm text-blue-500 hover:underline\"\n        />\n      </div>\n\n      {/* 自定义仓库 */}\n      <div className=\"mt-4 space-y-2\">\n        <label className=\"text-sm font-medium\">{t('settings.sync.customSyncRepo')}</label>\n        <Input\n          value={gitlabCustomSyncRepo}\n          onChange={(e) => setGitlabCustomSyncRepo(e.target.value)}\n          placeholder={RepoNames.sync}\n        />\n        <p className=\"text-xs text-muted-foreground\">{t('settings.sync.customSyncRepoDesc')}</p>\n      </div>\n\n      {/* 操作按钮 */}\n      <div className=\"mt-4 flex gap-2 flex-wrap\">\n        {gitlabAccessToken ? (\n          <>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={checkProjectState}\n              disabled={gitlabSyncProjectState === SyncStateEnum.checking || gitlabSyncProjectState === SyncStateEnum.creating}\n            >\n              {gitlabSyncProjectState === SyncStateEnum.checking || gitlabSyncProjectState === SyncStateEnum.creating ? (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1 animate-spin\" />\n                  {gitlabSyncProjectState === SyncStateEnum.checking ? t('settings.sync.checking') : t('settings.sync.creating')}\n                </>\n              ) : (\n                <>\n                  <RefreshCcw className=\"size-4 mr-1\" />\n                  {t('settings.sync.checkRepo')}\n                </>\n              )}\n            </Button>\n            {gitlabSyncProjectState === SyncStateEnum.fail && (\n              <Button variant=\"outline\" size=\"sm\" onClick={createGitlabProject}>\n                <Plus className=\"size-4 mr-1\" />\n                {t('settings.sync.createRepo')}\n              </Button>\n            )}\n          </>\n        ) : (\n          <div className=\"flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400\">\n            <RefreshCcw className=\"size-4\" />\n            {t('settings.sync.enterTokenHint')}\n          </div>\n        )}\n      </div>\n\n      {/* 仓库信息 */}\n      {gitlabSyncProjectInfo && (\n        <div className=\"border-t mt-4 pt-4\">\n          <div className=\"flex items-center gap-4\">\n            <Avatar className=\"size-10\">\n              <AvatarImage src={gitlabUserInfo?.avatar_url || ''} />\n            </Avatar>\n            <div>\n              <h3 className=\"text-xl font-bold mb-1\">\n                <OpenBroswer title={gitlabSyncProjectInfo?.name_with_namespace || ''} url={gitlabSyncProjectInfo?.web_url || ''} />\n              </h3>\n              <p className=\"text-sm text-zinc-500\">\n                {gitlabSyncProjectInfo?.visibility === 'public' ? t('settings.sync.public') : t('settings.sync.private')} · {t('settings.sync.createdAt', { time: dayjs(gitlabSyncProjectInfo?.created_at).fromNow() })} · {t('settings.sync.updatedAt', { time: dayjs(gitlabSyncProjectInfo?.updated_at).fromNow() })}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/page.tsx",
    "content": "'use client';\nimport { FileUp, FileDown, Files } from \"lucide-react\"\nimport { useTranslations } from 'next-intl';\nimport { GithubSync } from \"./github-sync\";\nimport { GiteeSync } from \"./gitee-sync\";\nimport { GitlabSync } from \"./gitlab-sync\";\nimport { GiteaSync } from \"./gitea-sync\";\nimport { S3Sync } from \"./s3-sync\";\nimport { WebDAVSync } from \"./webdav-sync\";\nimport { SettingType } from '../components/setting-base';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Loader2, RefreshCcw } from \"lucide-react\"\nimport useSettingStore from \"@/stores/setting\";\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { SYNC_PLATFORMS, SyncPlatform } from \"@/types/sync\";\nimport { Item, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemMedia } from \"@/components/ui/item\";\nimport useSyncStore from \"@/stores/sync\";\nimport { SyncStateEnum } from \"@/lib/sync/github.types\";\nimport { Switch } from \"@/components/ui/switch\";\n\nexport default function SyncPage() {\n  const t = useTranslations();\n  const {\n    primaryBackupMethod,\n    setPrimaryBackupMethod,\n    autoSync,\n    setAutoSync,\n    autoPullOnOpen,\n    setAutoPullOnOpen,\n    autoPullOnSwitch,\n    setAutoPullOnSwitch,\n  } = useSettingStore()\n  const { syncRepoState, giteeSyncRepoState, gitlabSyncProjectState, giteaSyncRepoState, s3Connected, webdavConnected } = useSyncStore()\n\n  const [tab, setTab] = useState<SyncPlatform>(primaryBackupMethod)\n  const [isLoading, setIsLoading] = useState(true)\n\n  useEffect(() => {\n    const init = async () => {\n      try {\n        const store = await Store.load('store.json')\n        const savedMethod = await store.get<SyncPlatform>('primaryBackupMethod')\n        if (savedMethod) {\n          setPrimaryBackupMethod(savedMethod)\n          setTab(savedMethod)\n        }\n      } catch (err) {\n        console.error('Failed to load primary backup method:', err)\n      } finally {\n        setIsLoading(false)\n      }\n    }\n    init()\n  }, [setPrimaryBackupMethod])\n\n  // Tab 切换时同步更新 Store\n  const handleTabChange = async (value: string) => {\n    const newTab = value as SyncPlatform\n    setTab(newTab)\n    await setPrimaryBackupMethod(newTab)\n  }\n\n  // 获取当前平台的同步状态\n  const getCurrentSyncState = () => {\n    switch (primaryBackupMethod) {\n      case 'github':\n        return syncRepoState\n      case 'gitee':\n        return giteeSyncRepoState\n      case 'gitlab':\n        return gitlabSyncProjectState\n      case 'gitea':\n        return giteaSyncRepoState\n      case 's3':\n        return s3Connected ? SyncStateEnum.success : SyncStateEnum.fail\n      case 'webdav':\n        return webdavConnected ? SyncStateEnum.success : SyncStateEnum.fail\n      default:\n        return syncRepoState\n    }\n  }\n\n  const currentSyncState = getCurrentSyncState()\n  const isAutoSyncDisabled = currentSyncState !== SyncStateEnum.success\n\n  if (isLoading) {\n    return (\n      <SettingType id=\"sync\" icon={<FileUp />} title={t('settings.sync.title')} desc={t('settings.sync.desc')}>\n        <div className=\"flex items-center justify-center py-12\">\n          <Loader2 className=\"size-8 animate-spin text-zinc-400\" />\n        </div>\n      </SettingType>\n    )\n  }\n\n  const renderSyncContent = () => {\n    switch (tab) {\n      case 'github':\n        return <GithubSync />\n      case 'gitee':\n        return <GiteeSync />\n      case 'gitlab':\n        return <GitlabSync />\n      case 'gitea':\n        return <GiteaSync />\n      case 's3':\n        return <S3Sync />\n      case 'webdav':\n        // TODO: Replace with WebDAV sync component in Task 4\n        return <WebDAVSync />\n      default:\n        return <GithubSync />\n    }\n  }\n\n  return (\n    <SettingType id=\"sync\" icon={<FileUp />} title={t('settings.sync.title')} desc={t('settings.sync.desc')}>\n      {/* 平台选择器 */}\n      <div className=\"mb-6\">\n        <h3 className=\"text-sm mb-2 font-bold\">{t('settings.sync.platformSettings')}</h3>\n        <Select value={tab} onValueChange={handleTabChange}>\n          <SelectTrigger className=\"w-50\">\n            <SelectValue placeholder={t('settings.sync.selectPlatform')} />\n          </SelectTrigger>\n          <SelectContent>\n            {SYNC_PLATFORMS.map((platform) => (\n              <SelectItem key={platform} value={platform}>\n                {platform.charAt(0).toUpperCase() + platform.slice(1)}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      {/* 同步平台内容 */}\n      {renderSyncContent()}\n\n      {/* 全局自动同步设置 */}\n      <div className=\"mt-4\">\n        <h3 className=\"text-sm mb-2 font-bold\">{t('settings.sync.moreSettings')}</h3>\n        <Item variant=\"outline\">\n          <ItemMedia variant=\"icon\"><RefreshCcw className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.sync.autoSync')}</ItemTitle>\n            <ItemDescription>{t('settings.sync.autoSyncDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Select\n              value={autoSync}\n              onValueChange={(value) => setAutoSync(value)}\n              disabled={isAutoSyncDisabled}\n            >\n              <SelectTrigger className=\"w-45\">\n                <SelectValue placeholder={t('settings.sync.autoSyncOptions.placeholder')} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"disabled\">{t('settings.sync.autoSyncOptions.disabled')}</SelectItem>\n                <SelectItem value=\"2\">{t('settings.sync.autoSyncOptions.2s')}</SelectItem>\n                <SelectItem value=\"3\">{t('settings.sync.autoSyncOptions.3s')}</SelectItem>\n                <SelectItem value=\"5\">{t('settings.sync.autoSyncOptions.5s')}</SelectItem>\n                <SelectItem value=\"10\">{t('settings.sync.autoSyncOptions.10s')}</SelectItem>\n                <SelectItem value=\"20\">{t('settings.sync.autoSyncOptions.20s')}</SelectItem>\n                <SelectItem value=\"30\">{t('settings.sync.autoSyncOptions.30s')}</SelectItem>\n                <SelectItem value=\"60\">{t('settings.sync.autoSyncOptions.1m')}</SelectItem>\n                <SelectItem value=\"120\">{t('settings.sync.autoSyncOptions.2m')}</SelectItem>\n              </SelectContent>\n            </Select>\n          </ItemActions>\n        </Item>\n\n        {/* 打开文件时自动拉取 */}\n        <Item variant=\"outline\" className=\"mt-2\">\n          <ItemMedia variant=\"icon\"><FileDown className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.sync.autoPullOnOpen')}</ItemTitle>\n            <ItemDescription>{t('settings.sync.autoPullOnOpenDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              checked={autoPullOnOpen}\n              onCheckedChange={setAutoPullOnOpen}\n              disabled={isAutoSyncDisabled}\n            />\n          </ItemActions>\n        </Item>\n\n        {/* 切换文件时自动拉取 */}\n        <Item variant=\"outline\" className=\"mt-2\">\n          <ItemMedia variant=\"icon\"><Files className=\"size-4\" /></ItemMedia>\n          <ItemContent>\n            <ItemTitle>{t('settings.sync.autoPullOnSwitch')}</ItemTitle>\n            <ItemDescription>{t('settings.sync.autoPullOnSwitchDesc')}</ItemDescription>\n          </ItemContent>\n          <ItemActions>\n            <Switch\n              checked={autoPullOnSwitch}\n              onCheckedChange={setAutoPullOnSwitch}\n              disabled={isAutoSyncDisabled}\n            />\n          </ItemActions>\n        </Item>\n      </div>\n    </SettingType>\n  )\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/s3-sync.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { useTranslations } from 'next-intl';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Eye, EyeOff, CheckCircle, XCircle, Loader2, Save } from 'lucide-react';\nimport { testS3Connection } from '@/lib/sync/s3';\nimport { S3Config } from '@/types/sync';\nimport { Store } from '@tauri-apps/plugin-store';\nimport useSyncStore from '@/stores/sync';\n\nexport function S3Sync() {\n  const t = useTranslations();\n  const { s3Connected, setS3Connected } = useSyncStore();\n\n  const [config, setConfig] = useState<S3Config>({\n    accessKeyId: '',\n    secretAccessKey: '',\n    region: 'us-east-1',\n    bucket: '',\n    endpoint: '',\n    pathPrefix: '',\n    customDomain: ''\n  });\n\n  const [showSecretKey, setShowSecretKey] = useState(false);\n  const [isConnecting, setIsConnecting] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n\n  // 初始化配置\n  useEffect(() => {\n    const initConfig = async () => {\n      const store = await Store.load('store.json');\n      const savedConfig = await store.get<S3Config>('s3SyncConfig');\n      if (savedConfig) {\n        setConfig(savedConfig);\n        // 如果配置完整，自动进行连接检测\n        if (savedConfig.accessKeyId && savedConfig.secretAccessKey && savedConfig.region && savedConfig.bucket) {\n          testConnection(savedConfig);\n        }\n      }\n    };\n    initConfig();\n  }, []);\n\n  // 测试连接\n  const testConnection = async (configToTest?: S3Config) => {\n    const testConfig = configToTest || config;\n    if (!testConfig.accessKeyId || !testConfig.secretAccessKey || !testConfig.region || !testConfig.bucket) {\n      return;\n    }\n\n    setIsConnecting(true);\n    try {\n      const isConnected = await testS3Connection(testConfig);\n      setS3Connected(isConnected);\n    } catch (error) {\n      console.error('S3 connection test failed:', error);\n      setS3Connected(false);\n    } finally {\n      setIsConnecting(false);\n    }\n  };\n\n  // 保存配置\n  const handleSave = async () => {\n    setIsSaving(true);\n    try {\n      const store = await Store.load('store.json');\n      await store.set('s3SyncConfig', config);\n      await store.save();\n      // 保存后自动测试连接\n      await testConnection(config);\n    } catch (error) {\n      console.error('Failed to save S3 config:', error);\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  // 配置变更处理\n  const handleConfigChange = (key: keyof S3Config, value: string) => {\n    setConfig(prev => ({ ...prev, [key]: value }));\n  };\n\n  const getStatusIcon = () => {\n    if (isConnecting) {\n      return <Loader2 className=\"size-4 animate-spin text-blue-500\" />;\n    }\n    if (s3Connected) {\n      return <CheckCircle className=\"size-4 text-green-500\" />;\n    }\n    return <XCircle className=\"size-4 text-red-500\" />;\n  };\n\n  const getStatusText = () => {\n    if (isConnecting) {\n      return t('settings.sync.s3.connecting');\n    }\n    if (s3Connected) {\n      return t('settings.sync.s3.connected');\n    }\n    return t('settings.sync.s3.disconnected');\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>{t('settings.sync.s3.title')}</CardTitle>\n            <CardDescription>\n              {t('settings.sync.s3.description')}\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">{t('settings.sync.s3.status')}</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n        {/* 基本配置 */}\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"accessKeyId\">{t('settings.sync.s3.accessKeyId')}</Label>\n            <Input\n              id=\"accessKeyId\"\n              type=\"text\"\n              value={config.accessKeyId}\n              onChange={(e) => handleConfigChange('accessKeyId', e.target.value)}\n              placeholder={t('settings.sync.s3.accessKeyIdPlaceholder')}\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"secretAccessKey\">{t('settings.sync.s3.secretAccessKey')}</Label>\n            <div className=\"relative\">\n              <Input\n                id=\"secretAccessKey\"\n                type={showSecretKey ? \"text\" : \"password\"}\n                value={config.secretAccessKey}\n                onChange={(e) => handleConfigChange('secretAccessKey', e.target.value)}\n                placeholder={t('settings.sync.s3.secretAccessKeyPlaceholder')}\n                className=\"pr-10\"\n              />\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent\"\n                onClick={() => setShowSecretKey(!showSecretKey)}\n              >\n                {showSecretKey ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n              </Button>\n            </div>\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"region\">{t('settings.sync.s3.region')}</Label>\n            <Input\n              id=\"region\"\n              type=\"text\"\n              value={config.region}\n              onChange={(e) => handleConfigChange('region', e.target.value)}\n              placeholder=\"us-east-1\"\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"bucket\">{t('settings.sync.s3.bucket')}</Label>\n            <Input\n              id=\"bucket\"\n              type=\"text\"\n              value={config.bucket}\n              onChange={(e) => handleConfigChange('bucket', e.target.value)}\n              placeholder={t('settings.sync.s3.bucketPlaceholder')}\n            />\n          </div>\n        </div>\n\n        {/* 高级配置 */}\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"endpoint\">{t('settings.sync.s3.endpoint')}</Label>\n            <Input\n              id=\"endpoint\"\n              type=\"text\"\n              value={config.endpoint || ''}\n              onChange={(e) => handleConfigChange('endpoint', e.target.value)}\n              placeholder=\"https://s3.amazonaws.com\"\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"pathPrefix\">{t('settings.sync.s3.pathPrefix')}</Label>\n            <Input\n              id=\"pathPrefix\"\n              type=\"text\"\n              value={config.pathPrefix || ''}\n              onChange={(e) => handleConfigChange('pathPrefix', e.target.value)}\n              placeholder={t('settings.sync.s3.pathPrefixPlaceholder')}\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              {t('settings.sync.s3.pathPrefixDesc')}\n            </p>\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"customDomain\">{t('settings.sync.s3.customDomain')}</Label>\n            <Input\n              id=\"customDomain\"\n              type=\"text\"\n              value={config.customDomain || ''}\n              onChange={(e) => handleConfigChange('customDomain', e.target.value)}\n              placeholder=\"https://cdn.example.com\"\n            />\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex gap-2 pt-2\">\n          <Button\n            variant=\"outline\"\n            onClick={() => testConnection()}\n            disabled={isConnecting || !config.accessKeyId || !config.secretAccessKey || !config.region || !config.bucket}\n          >\n            {isConnecting ? (\n              <>\n                <Loader2 className=\"size-4 mr-2 animate-spin\" />\n                {t('settings.sync.s3.testing')}\n              </>\n            ) : (\n              t('settings.sync.s3.testConnection')\n            )}\n          </Button>\n          <Button\n            onClick={handleSave}\n            disabled={isSaving}\n          >\n            {isSaving ? (\n              <>\n                <Loader2 className=\"size-4 mr-2 animate-spin\" />\n                {t('settings.sync.s3.saving')}\n              </>\n            ) : (\n              <>\n                <Save className=\"size-4 mr-2\" />\n                {t('settings.sync.s3.saveConfig')}\n              </>\n            )}\n          </Button>\n        </div>\n\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/app/core/setting/sync/webdav-sync.tsx",
    "content": "'use client';\n\nimport { useState, useEffect } from 'react';\nimport { useTranslations } from 'next-intl';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Eye, EyeOff, CheckCircle, XCircle, Loader2, Save } from 'lucide-react';\nimport { testWebDAVConnection } from '@/lib/sync/webdav';\nimport { WebDAVConfig } from '@/types/sync';\nimport { Store } from '@tauri-apps/plugin-store';\nimport useSyncStore from '@/stores/sync';\n\nexport function WebDAVSync() {\n  const t = useTranslations();\n  const { webdavConnected, setWebDAVConnected } = useSyncStore();\n\n  const [config, setConfig] = useState<WebDAVConfig>({\n    url: '',\n    username: '',\n    password: '',\n    pathPrefix: ''\n  });\n\n  const [showPassword, setShowPassword] = useState(false);\n  const [isConnecting, setIsConnecting] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n\n  // 初始化配置\n  useEffect(() => {\n    const initConfig = async () => {\n      const store = await Store.load('store.json');\n      const savedConfig = await store.get<WebDAVConfig>('webdavSyncConfig');\n      if (savedConfig) {\n        setConfig(savedConfig);\n        // 如果配置完整，自动进行连接检测\n        if (savedConfig.url && savedConfig.username && savedConfig.password) {\n          testConnection(savedConfig);\n        }\n      }\n    };\n    initConfig();\n  }, []);\n\n  // 测试连接\n  const testConnection = async (configToTest?: WebDAVConfig) => {\n    const testConfig = configToTest || config;\n    if (!testConfig.url || !testConfig.username || !testConfig.password) {\n      return;\n    }\n\n    setIsConnecting(true);\n    try {\n      const isConnected = await testWebDAVConnection(testConfig);\n      setWebDAVConnected(isConnected);\n    } catch (error) {\n      console.error('WebDAV connection test failed:', error);\n      setWebDAVConnected(false);\n    } finally {\n      setIsConnecting(false);\n    }\n  };\n\n  // 保存配置\n  const handleSave = async () => {\n    setIsSaving(true);\n    try {\n      const store = await Store.load('store.json');\n      await store.set('webdavSyncConfig', config);\n      await store.save();\n      // 保存后自动测试连接\n      await testConnection(config);\n    } catch (error) {\n      console.error('Failed to save WebDAV config:', error);\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  // 配置变更处理\n  const handleConfigChange = (key: keyof WebDAVConfig, value: string) => {\n    setConfig(prev => ({ ...prev, [key]: value }));\n  };\n\n  const getStatusIcon = () => {\n    if (isConnecting) {\n      return <Loader2 className=\"size-4 animate-spin text-blue-500\" />;\n    }\n    if (webdavConnected) {\n      return <CheckCircle className=\"size-4 text-green-500\" />;\n    }\n    return <XCircle className=\"size-4 text-red-500\" />;\n  };\n\n  const getStatusText = () => {\n    if (isConnecting) {\n      return t('settings.sync.webdav.connecting');\n    }\n    if (webdavConnected) {\n      return t('settings.sync.webdav.connected');\n    }\n    return t('settings.sync.webdav.disconnected');\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>{t('settings.sync.webdav.title')}</CardTitle>\n            <CardDescription>\n              {t('settings.sync.webdav.description')}\n            </CardDescription>\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* 状态显示 */}\n        <div className=\"flex items-center justify-between p-3 bg-muted rounded-lg\">\n          <span className=\"text-sm font-medium\">{t('settings.sync.webdav.status')}</span>\n          <div className=\"flex items-center gap-2\">\n            {getStatusIcon()}\n            <span className=\"text-sm\">{getStatusText()}</span>\n          </div>\n        </div>\n\n        {/* 基本配置 */}\n        <div className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"url\">{t('settings.sync.webdav.url')}</Label>\n            <Input\n              id=\"url\"\n              type=\"text\"\n              value={config.url}\n              onChange={(e) => handleConfigChange('url', e.target.value)}\n              placeholder={t('settings.sync.webdav.urlPlaceholder')}\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              {t('settings.sync.webdav.urlDesc')}\n            </p>\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"username\">{t('settings.sync.webdav.username')}</Label>\n            <Input\n              id=\"username\"\n              type=\"text\"\n              value={config.username}\n              onChange={(e) => handleConfigChange('username', e.target.value)}\n              placeholder={t('settings.sync.webdav.usernamePlaceholder')}\n            />\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"password\">{t('settings.sync.webdav.password')}</Label>\n            <div className=\"relative\">\n              <Input\n                id=\"password\"\n                type={showPassword ? \"text\" : \"password\"}\n                value={config.password}\n                onChange={(e) => handleConfigChange('password', e.target.value)}\n                placeholder={t('settings.sync.webdav.passwordPlaceholder')}\n                className=\"pr-10\"\n              />\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent\"\n                onClick={() => setShowPassword(!showPassword)}\n              >\n                {showPassword ? <EyeOff className=\"size-4\" /> : <Eye className=\"size-4\" />}\n              </Button>\n            </div>\n          </div>\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"pathPrefix\">{t('settings.sync.webdav.pathPrefix')}</Label>\n            <Input\n              id=\"pathPrefix\"\n              type=\"text\"\n              value={config.pathPrefix || ''}\n              onChange={(e) => handleConfigChange('pathPrefix', e.target.value)}\n              placeholder={t('settings.sync.webdav.pathPrefixPlaceholder')}\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              {t('settings.sync.webdav.pathPrefixDesc')}\n            </p>\n          </div>\n        </div>\n\n        {/* 操作按钮 */}\n        <div className=\"flex gap-2 pt-2\">\n          <Button\n            variant=\"outline\"\n            onClick={() => testConnection()}\n            disabled={isConnecting || !config.url || !config.username || !config.password}\n          >\n            {isConnecting ? (\n              <>\n                <Loader2 className=\"size-4 mr-2 animate-spin\" />\n                {t('settings.sync.webdav.testing')}\n              </>\n            ) : (\n              t('settings.sync.webdav.testConnection')\n            )}\n          </Button>\n          <Button\n            onClick={handleSave}\n            disabled={isSaving}\n          >\n            {isSaving ? (\n              <>\n                <Loader2 className=\"size-4 mr-2 animate-spin\" />\n                {t('settings.sync.webdav.saving')}\n              </>\n            ) : (\n              <>\n                <Save className=\"size-4 mr-2\" />\n                {t('settings.sync.webdav.saveConfig')}\n              </>\n            )}\n          </Button>\n        </div>\n\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "src/app/core/setting/template/page.tsx",
    "content": "'use client';\n\nimport { LayoutTemplate } from \"lucide-react\"\nimport { SettingTemplate } from \"./setting-template\";\n\nexport default function TemplatePage() {\n  return <SettingTemplate id=\"template\" icon={<LayoutTemplate />} />\n}\n"
  },
  {
    "path": "src/app/core/setting/template/setting-template.tsx",
    "content": "import useSettingStore, { GenTemplate, GenTemplateRange } from \"@/stores/setting\";\nimport { SettingType } from \"../components/setting-base\";\nimport { useTranslations } from 'next-intl';\nimport { getTemplateRangeLabel, getTemplateRangeOptions } from '@/lib/template-range-utils';\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Plus, Trash, Pencil } from \"lucide-react\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useEffect, useState } from \"react\";\nimport { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { confirm } from '@tauri-apps/plugin-dialog';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\";\nimport { Label } from \"@/components/ui/label\";\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\n\nexport function SettingTemplate({id, icon}: {id: string, icon?: React.ReactNode}) {\n  const t = useTranslations();\n  const { templateList, setTemplateList } = useSettingStore();\n  const [dialogOpen, setDialogOpen] = useState(false);\n  const [editDialogOpen, setEditDialogOpen] = useState(false);\n  const [currentTemplate, setCurrentTemplate] = useState<GenTemplate | null>(null);\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n\n  // Form states\n  const [templateTitle, setTemplateTitle] = useState('');\n  const [templateContent, setTemplateContent] = useState('');\n  const [templateRange, setTemplateRange] = useState<GenTemplateRange>(GenTemplateRange.All);\n  const [templateStatus, setTemplateStatus] = useState(true);\n\n  function createTemplateHandler() {\n    const newTemplate: GenTemplate = {\n      id: `${templateList.length + 1}`,\n      status: templateStatus,\n      title: templateTitle || t('settings.template.customTemplate'),\n      content: templateContent,\n      range: templateRange,\n    };\n    \n    setTemplateList([...templateList, newTemplate]);\n    resetForm();\n    setDialogOpen(false);\n  }\n\n  function updateTemplateHandler() {\n    if (!currentTemplate) return;\n    \n    setTemplateList(templateList.map(item => {\n      if (item.id === currentTemplate.id) {\n        return {\n          ...item,\n          title: templateTitle,\n          content: templateContent,\n          range: templateRange,\n          status: templateStatus\n        };\n      }\n      return item;\n    }));\n    \n    setEditDialogOpen(false);\n    resetForm();\n  }\n\n  function deleteTemplateHandler(id: string) {\n    confirm(t('settings.template.deleteConfirm')).then(async (res) => {\n      if (res) {\n        setTemplateList(templateList.filter(item => item.id !== id));\n      }\n    });\n  }\n\n  function openAddDialog() {\n    resetForm();\n    setDialogOpen(true);\n  }\n\n  function openEditDialog(template: GenTemplate) {\n    setCurrentTemplate(template);\n    setTemplateTitle(template.title);\n    setTemplateContent(template.content);\n    setTemplateRange(template.range);\n    setTemplateStatus(template.status);\n    setEditDialogOpen(true);\n  }\n\n  function resetForm() {\n    setTemplateTitle('');\n    setTemplateContent('');\n    setTemplateRange(GenTemplateRange.All);\n    setTemplateStatus(true);\n    setCurrentTemplate(null);\n  }\n\n  useEffect(() => {}, [templateList]);\n\n  return (\n    <SettingType id={id} icon={icon} title={t('settings.template.title')} desc={t('settings.template.desc')}>\n      <div className=\"flex flex-col gap-4\">\n        <div className=\"flex justify-between items-center\">\n          {isMobile ? (\n            <Drawer open={dialogOpen} onOpenChange={setDialogOpen}>\n              <DrawerTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" onClick={openAddDialog}>\n                  <Plus className=\"h-4 w-4 mr-2\" />\n                  {t('settings.template.addTemplate')}\n                </Button>\n              </DrawerTrigger>\n              <DrawerContent>\n                <DrawerHeader>\n                  <DrawerTitle>\n                    {t('settings.template.addTemplate')}\n                  </DrawerTitle>\n                  <DrawerDescription>\n                    {t('settings.template.addTemplateDesc') || t('settings.template.customTemplate')}\n                  </DrawerDescription>\n                </DrawerHeader>\n                <div className=\"grid gap-4 px-4\">\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"title\">{t('settings.template.name')}</Label>\n                    <Input\n                      id=\"title\"\n                      value={templateTitle}\n                      onChange={(e) => setTemplateTitle(e.target.value)}\n                      placeholder={t('settings.template.name')}\n                    />\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <div className=\"flex justify-between\">\n                      <Label htmlFor=\"range\">{t('settings.template.scope')}</Label>\n                      <div className=\"flex items-center gap-2\">\n                        <Label htmlFor=\"status\">{t('settings.template.status')}</Label>\n                        <Switch\n                          id=\"status\"\n                          checked={templateStatus}\n                          onCheckedChange={setTemplateStatus}\n                        />\n                      </div>\n                    </div>\n                    <Select\n                      value={templateRange}\n                      onValueChange={(value: GenTemplateRange) => setTemplateRange(value)}\n                    >\n                      <SelectTrigger>\n                        <SelectValue placeholder={t('settings.template.selectScope')} />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectGroup>\n                          {getTemplateRangeOptions(t).map((option) => (\n                            <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n                          ))}\n                        </SelectGroup>\n                      </SelectContent>\n                    </Select>\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"content\">{t('settings.template.content')}</Label>\n                    <Textarea\n                      id=\"content\"\n                      rows={5}\n                      value={templateContent}\n                      onChange={(e) => setTemplateContent(e.target.value)}\n                      placeholder={t('settings.template.content')}\n                    />\n                  </div>\n                </div>\n                <DrawerFooter>\n                  <Button variant=\"outline\" onClick={() => setDialogOpen(false)}>{t('common.cancel') || 'Cancel'}</Button>\n                  <Button onClick={createTemplateHandler}>{t('common.confirm') || 'Confirm'}</Button>\n                </DrawerFooter>\n              </DrawerContent>\n            </Drawer>\n          ) : (\n            <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>\n              <DialogTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\" onClick={openAddDialog}>\n                  <Plus className=\"h-4 w-4 mr-2\" />\n                  {t('settings.template.addTemplate')}\n                </Button>\n              </DialogTrigger>\n              <DialogContent>\n                <DialogHeader>\n                  <DialogTitle>\n                    {t('settings.template.addTemplate')}\n                  </DialogTitle>\n                  <DialogDescription>\n                    {t('settings.template.addTemplateDesc') || t('settings.template.customTemplate')}\n                  </DialogDescription>\n                </DialogHeader>\n                <div className=\"grid gap-4 py-4\">\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"title\">{t('settings.template.name')}</Label>\n                    <Input\n                      id=\"title\"\n                      value={templateTitle}\n                      onChange={(e) => setTemplateTitle(e.target.value)}\n                      placeholder={t('settings.template.name')}\n                    />\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <div className=\"flex justify-between\">\n                      <Label htmlFor=\"range\">{t('settings.template.scope')}</Label>\n                      <div className=\"flex items-center gap-2\">\n                        <Label htmlFor=\"status\">{t('settings.template.status')}</Label>\n                        <Switch\n                          id=\"status\"\n                          checked={templateStatus}\n                          onCheckedChange={setTemplateStatus}\n                        />\n                      </div>\n                    </div>\n                    <Select\n                      value={templateRange}\n                      onValueChange={(value: GenTemplateRange) => setTemplateRange(value)}\n                    >\n                      <SelectTrigger>\n                        <SelectValue placeholder={t('settings.template.selectScope')} />\n                      </SelectTrigger>\n                      <SelectContent>\n                        <SelectGroup>\n                          {getTemplateRangeOptions(t).map((option) => (\n                            <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n                          ))}\n                        </SelectGroup>\n                      </SelectContent>\n                    </Select>\n                  </div>\n                  <div className=\"grid gap-2\">\n                    <Label htmlFor=\"content\">{t('settings.template.content')}</Label>\n                    <Textarea\n                      id=\"content\"\n                      rows={5}\n                      value={templateContent}\n                      onChange={(e) => setTemplateContent(e.target.value)}\n                      placeholder={t('settings.template.content')}\n                    />\n                  </div>\n                </div>\n                <DialogFooter>\n                  <Button variant=\"outline\" onClick={() => setDialogOpen(false)}>{t('common.cancel') || 'Cancel'}</Button>\n                  <Button onClick={createTemplateHandler}>{t('common.confirm') || 'Confirm'}</Button>\n                </DialogFooter>\n              </DialogContent>\n            </Dialog>\n          )}\n        </div>\n\n        {/* Edit Template Dialog/Drawer */}\n        {isMobile ? (\n          <Drawer open={editDialogOpen} onOpenChange={setEditDialogOpen}>\n            <DrawerContent>\n              <DrawerHeader>\n                <DrawerTitle>\n                  {t('settings.template.editTemplate') || 'Edit Template'}\n                </DrawerTitle>\n              </DrawerHeader>\n              <div className=\"grid gap-4 px-4\">\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"edit-title\">{t('settings.template.name')}</Label>\n                  <Input\n                    id=\"edit-title\"\n                    value={templateTitle}\n                    onChange={(e) => setTemplateTitle(e.target.value)}\n                    placeholder={t('settings.template.name')}\n                  />\n                </div>\n                <div className=\"grid gap-2\">\n                  <div className=\"flex justify-between\">\n                    <Label htmlFor=\"edit-range\">{t('settings.template.scope')}</Label>\n                    <div className=\"flex items-center gap-2\">\n                      <Label htmlFor=\"edit-status\">{t('settings.template.status')}</Label>\n                      <Switch\n                        id=\"edit-status\"\n                        checked={templateStatus}\n                        onCheckedChange={setTemplateStatus}\n                        disabled={currentTemplate?.id === '0'}\n                      />\n                    </div>\n                  </div>\n                  <Select\n                    value={templateRange}\n                    onValueChange={(value: GenTemplateRange) => setTemplateRange(value)}\n                  >\n                    <SelectTrigger>\n                      <SelectValue placeholder={t('settings.template.selectScope')} />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectGroup>\n                        {getTemplateRangeOptions(t).map((option) => (\n                          <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n                        ))}\n                      </SelectGroup>\n                    </SelectContent>\n                  </Select>\n                </div>\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"edit-content\">{t('settings.template.content')}</Label>\n                  <Textarea\n                    id=\"edit-content\"\n                    rows={5}\n                    value={templateContent}\n                    onChange={(e) => setTemplateContent(e.target.value)}\n                    placeholder={t('settings.template.content')}\n                  />\n                </div>\n              </div>\n              <DrawerFooter>\n                <Button variant=\"outline\" onClick={() => setEditDialogOpen(false)}>{t('common.cancel') || 'Cancel'}</Button>\n                <Button onClick={updateTemplateHandler}>{t('common.confirm') || 'Confirm'}</Button>\n              </DrawerFooter>\n            </DrawerContent>\n          </Drawer>\n        ) : (\n          <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>\n            <DialogContent>\n              <DialogHeader>\n                <DialogTitle>\n                  {t('settings.template.editTemplate') || 'Edit Template'}\n                </DialogTitle>\n              </DialogHeader>\n              <div className=\"grid gap-4 py-4\">\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"edit-title\">{t('settings.template.name')}</Label>\n                  <Input\n                    id=\"edit-title\"\n                    value={templateTitle}\n                    onChange={(e) => setTemplateTitle(e.target.value)}\n                    placeholder={t('settings.template.name')}\n                  />\n                </div>\n                <div className=\"grid gap-2\">\n                  <div className=\"flex justify-between\">\n                    <Label htmlFor=\"edit-range\">{t('settings.template.scope')}</Label>\n                    <div className=\"flex items-center gap-2\">\n                      <Label htmlFor=\"edit-status\">{t('settings.template.status')}</Label>\n                      <Switch\n                        id=\"edit-status\"\n                        checked={templateStatus}\n                        onCheckedChange={setTemplateStatus}\n                        disabled={currentTemplate?.id === '0'}\n                      />\n                    </div>\n                  </div>\n                  <Select\n                    value={templateRange}\n                    onValueChange={(value: GenTemplateRange) => setTemplateRange(value)}\n                  >\n                    <SelectTrigger>\n                      <SelectValue placeholder={t('settings.template.selectScope')} />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectGroup>\n                        {getTemplateRangeOptions(t).map((option) => (\n                          <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>\n                        ))}\n                      </SelectGroup>\n                    </SelectContent>\n                  </Select>\n                </div>\n                <div className=\"grid gap-2\">\n                  <Label htmlFor=\"edit-content\">{t('settings.template.content')}</Label>\n                  <Textarea\n                    id=\"edit-content\"\n                    rows={5}\n                    value={templateContent}\n                    onChange={(e) => setTemplateContent(e.target.value)}\n                    placeholder={t('settings.template.content')}\n                  />\n                </div>\n              </div>\n              <DialogFooter>\n                <Button variant=\"outline\" onClick={() => setEditDialogOpen(false)}>{t('common.cancel') || 'Cancel'}</Button>\n                <Button onClick={updateTemplateHandler}>{t('common.confirm') || 'Confirm'}</Button>\n              </DialogFooter>\n            </DialogContent>\n          </Dialog>\n        )}\n\n        <div className=\"grid gap-4\">\n          {templateList.map((item) => (\n            <Card key={item.id}>\n              <CardContent className=\"p-4\">\n                <div className=\"flex flex-col gap-2\">\n                  <div className=\"flex justify-between items-center\">\n                    <div className={`${!item.status ? 'opacity-50' : ''}`}>\n                      <h3 className=\"font-medium\">{item.title}</h3>\n                    </div>\n                    <div className=\"flex items-center gap-2\">\n                      <Button\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        onClick={() => openEditDialog(item)}\n                      >\n                        <Pencil className=\"h-4 w-4\" />\n                      </Button>\n                      <Button\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        onClick={() => deleteTemplateHandler(item.id)}\n                        disabled={item.id === '0'}\n                      >\n                        <Trash className=\"h-4 w-4\" />\n                      </Button>\n                    </div>\n                  </div>\n                  <div className=\"text-sm text-muted-foreground\">\n                    {t('settings.template.scope')}: <span className=\"font-medium\">{getTemplateRangeLabel(item.range, t)}</span>\n                  </div>\n                  <p className={`text-sm whitespace-pre-wrap mt-2 line-clamp-3 ${!item.status ? 'opacity-50' : ''}`}>\n                    {item.content || t('settings.template.noContent') || 'No content'}\n                  </p>\n                </div>\n              </CardContent>\n            </Card>\n          ))}\n        </div>\n      </div>\n    </SettingType>\n  );\n}"
  },
  {
    "path": "src/app/error.tsx",
    "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { useEffect } from 'react';\n\nexport default function Error({\n  error,\n}: {\n  error: Error & { digest?: string };\n}) {\n  useEffect(() => {\n    // 记录错误到控制台\n    console.error('应用错误:', error);\n  }, [error]);\n\n  function reloadPage() {\n    window.location.reload();\n  }\n\n  return (\n    <div className=\"flex flex-col items-center justify-center p-4 min-h-[200px]\">\n      <div className=\"bg-card p-6 rounded-lg border shadow max-w-md w-full\">\n        <h2 className=\"text-lg font-semibold mb-4\">出错了</h2>\n        <p className=\"text-sm text-muted-foreground mb-4\">\n          应用程序的这部分出现了问题，但您可以继续使用其他功能。\n        </p>\n        <p className=\"text-xs bg-muted p-2 rounded mb-4 overflow-auto max-h-[100px]\">\n          {error.message || '未知错误'}\n        </p>\n        <div className=\"flex justify-end\">\n          <Button onClick={reloadPage} variant=\"outline\" size=\"sm\">\n            重试\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/global-error.tsx",
    "content": "'use client';\n\nimport { Button } from '@/components/ui/button';\nimport { useEffect } from 'react';\n\nexport default function GlobalError({\n  error,\n}: {\n  error: Error & { digest?: string };\n}) {\n  useEffect(() => {\n    console.error('全局错误:', error);\n  }, [error]);\n\n  function reloadPage() {\n    window.location.reload();\n  }\n\n  return (\n    <html lang=\"zh\">\n      <body>\n        <div className=\"flex flex-col items-center justify-center h-screen p-4\">\n          <div className=\"bg-white dark:bg-gray-800 p-6 rounded-lg border shadow max-w-md w-full\">\n            <h2 className=\"text-lg font-semibold mb-4\">系统错误</h2>\n            <p className=\"text-sm text-gray-600 dark:text-gray-300 mb-4\">\n              应用程序遇到了问题，但我们正在努力修复。\n            </p>\n            <p className=\"text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mb-4 overflow-auto max-h-[120px]\">\n              {error.message || '未知错误'}\n            </p>\n            <div className=\"flex justify-end\">\n              <Button \n                onClick={reloadPage} \n                className=\"px-4 py-2 bg-blue-500 text-white text-sm rounded hover:bg-blue-600\"\n              >\n                重试\n              </Button>\n            </div>\n          </div>\n        </div>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "src/app/globals.css",
    "content": "@import \"tailwindcss\";\n\n/* 导入动画库 */\n@import \"tw-animate-css\";\n\n@custom-variant dark (&:is(.dark *));\n\n/* 配置 dark mode 变体 - 必须在 @theme 外面 */\n@variant dark (&:where(.dark, .dark *));\n\n/* 使用 @theme inline 替代 tailwind.config.ts */\n@theme inline {\n  /* 颜色映射 */\n  --color-background: hsl(var(--background));\n  --color-foreground: hsl(var(--foreground));\n  --color-card: hsl(var(--card));\n  --color-card-foreground: hsl(var(--card-foreground));\n  --color-popover: hsl(var(--popover));\n  --color-popover-foreground: hsl(var(--popover-foreground));\n  --color-primary: hsl(var(--primary));\n  --color-primary-foreground: hsl(var(--primary-foreground));\n  --color-secondary: hsl(var(--secondary));\n  --color-secondary-foreground: hsl(var(--secondary-foreground));\n  --color-third: hsl(var(--third));\n  --color-third-foreground: hsl(var(--third-foreground));\n  --color-muted: hsl(var(--muted));\n  --color-muted-foreground: hsl(var(--muted-foreground));\n  --color-accent: hsl(var(--accent));\n  --color-accent-foreground: hsl(var(--accent-foreground));\n  --color-destructive: hsl(var(--destructive));\n  --color-destructive-foreground: hsl(var(--destructive-foreground));\n  --color-border: hsl(var(--border));\n  --color-input: hsl(var(--input));\n  --color-ring: hsl(var(--ring));\n\n  /* 边框圆角 */\n  --radius-lg: var(--radius);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-sm: calc(var(--radius) - 4px);\n\n  /* 自定义动画 */\n  @keyframes accordion-down {\n    from {\n      height: 0;\n    }\n    to {\n      height: var(--radix-accordion-content-height);\n    }\n  }\n\n  @keyframes accordion-up {\n    from {\n      height: var(--radix-accordion-content-height);\n    }\n    to {\n      height: 0;\n    }\n  }\n\n  @keyframes iconBounce {\n    0%, 100% {\n      transform: translateY(0);\n    }\n    20% {\n      transform: translateY(-0.3em);\n    }\n    40% {\n      transform: translateY(0);\n    }\n    60% {\n      transform: translateY(-0.1em);\n    }\n    80% {\n      transform: translateY(0);\n    }\n  }\n\n  --animate-shine: shine var(--duration) infinite linear\n;\n  @keyframes shine {\n  0% {\n    background-position: 0% 0%;}\n  50% {\n    background-position: 100% 100%;}\n  to {\n    background-position: 0% 0%;}}}\n\n@keyframes recordSearchBreathe {\n  0% {\n    box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.12);\n    border-color: rgba(245, 158, 11, 0.45);\n    background-color: rgba(254, 243, 199, 0.4);\n  }\n\n  50% {\n    box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.18);\n    border-color: rgba(245, 158, 11, 0.9);\n    background-color: rgba(254, 243, 199, 0.9);\n  }\n\n  100% {\n    box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.1);\n    border-color: rgba(245, 158, 11, 0.35);\n    background-color: rgba(254, 243, 199, 0.35);\n  }\n}\n\n.record-search-highlight {\n  animation: recordSearchBreathe 1.5s ease-in-out 2;\n}\n\n/* 基础样式 - 包含主题变量定义 */\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 240 10% 3.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 240 10% 3.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 240 10% 3.9%;\n    --primary: 240 5.9% 10%;\n    --primary-foreground: 0 0% 98%;\n    --secondary: 240 4.8% 95.9%;\n    --secondary-foreground: 240 5.9% 10%;\n    --third: 240 4.8% 90.9%;\n    --third-foreground: 240 5.9% 15%;\n    --muted: 240 4.8% 95.9%;\n    --muted-foreground: 240 3.8% 46.1%;\n    --accent: 240 4.8% 95.9%;\n    --accent-foreground: 240 5.9% 10%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 240 5.9% 90%;\n    --input: 240 5.9% 90%;\n    --ring: 240 10% 3.9%;\n    --shadow: 0 0% 0%;\n    --chart-1: 12 76% 61%;\n    --chart-2: 173 58% 39%;\n    --chart-3: 197 37% 24%;\n    --chart-4: 43 74% 66%;\n    --chart-5: 27 87% 67%;\n    --radius: 0.5rem;\n    --sidebar-background: 0 0% 98%;\n    --sidebar-foreground: 240 5.3% 26.1%;\n    --sidebar-primary: 240 5.9% 10%;\n    --sidebar-primary-foreground: 0 0% 98%;\n    --sidebar-accent: 240 4.8% 95.9%;\n    --sidebar-accent-foreground: 240 5.9% 10%;\n    --sidebar-border: 220 13% 91%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n    --component-inactive-color: var(--muted-foreground);\n    --component-bg: var(--card);\n    --component-shadow: var(--border);\n    --component-active-bg: var(--secondary);\n    --component-line-inactive-color: var(--border);\n    --component-active-color-default: var(--accent-foreground);\n  }\n\n  .dark {\n    --background: 240 10% 3.9%;\n    --foreground: 0 0% 98%;\n    --card: 240 10% 3.9%;\n    --card-foreground: 0 0% 98%;\n    --popover: 240 10% 3.9%;\n    --popover-foreground: 0 0% 98%;\n    --primary: 0 0% 98%;\n    --primary-foreground: 240 5.9% 10%;\n    --secondary: 240 3.7% 15.9%;\n    --secondary-foreground: 0 0% 98%;\n    --third: 240 3.7% 20.9%;\n    --third-foreground: 240 4.8% 90.9%;\n    --muted: 240 3.7% 15.9%;\n    --muted-foreground: 240 5% 64.9%;\n    --accent: 240 3.7% 15.9%;\n    --accent-foreground: 0 0% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 0 0% 98%;\n    --border: 240 3.7% 15.9%;\n    --input: 240 3.7% 15.9%;\n    --ring: 240 4.9% 83.9%;\n    --shadow: 0 0% 0%;\n    --chart-1: 220 70% 50%;\n    --chart-2: 160 60% 45%;\n    --chart-3: 30 80% 55%;\n    --chart-4: 280 65% 60%;\n    --chart-5: 340 75% 55%;\n    --sidebar-background: 240 5.9% 10%;\n    --sidebar-foreground: 240 4.8% 95.9%;\n    --sidebar-primary: 224.3 76.3% 48%;\n    --sidebar-primary-foreground: 0 0% 100%;\n    --sidebar-accent: 240 3.7% 15.9%;\n    --sidebar-accent-foreground: 240 4.8% 95.9%;\n    --sidebar-border: 240 3.7% 15.9%;\n    --sidebar-ring: 217.2 91.2% 59.8%;\n    --component-inactive-color: var(--muted-foreground);\n    --component-bg: var(--card);\n    --component-shadow: var(--border);\n    --component-active-bg: var(--secondary);\n    --component-line-inactive-color: var(--muted-foreground);\n    --component-active-color-default: var(--accent-foreground);\n  }\n\n  * {\n    border-color: hsl(var(--border));\n  }\n\n  body {\n    background-color: hsl(var(--background));\n    color: hsl(var(--foreground));\n  }\n}\n\n/* 自定义组件样式 - 使用 Tailwind 类以支持主题切换 */\nhtml, body {\n  height: 100vh;\n  overflow: hidden;\n}\n\n.file-manange-item {\n  display: flex;\n  align-items: center;\n  gap: 0.25rem;\n  padding: 0.25rem 0;\n  font-size: 0.875rem;\n  cursor: pointer;\n  width: 100%;\n}\n\n.file-manange-item:hover {\n  background-color: hsl(var(--third));\n}\n\n.dark .file-manange-item:hover {\n  background-color: hsl(var(--muted));\n}\n\n.file-manange-item.active {\n  background-color: hsl(var(--third));\n  color: hsl(var(--third-foreground));\n  font-weight: 700;\n}\n\n.dark .file-manange-item.active {\n  background-color: hsl(var(--secondary));\n  color: hsl(var(--secondary-foreground));\n}\n\n.file-manange-item.active .bg-primary-foreground {\n  background-color: hsl(var(--third));\n}\n\n.dark .file-manange-item.active .bg-primary-foreground {\n  background-color: hsl(var(--secondary-foreground));\n}\n\n:not(table)::-webkit-scrollbar {\n  width: 0.5rem;\n  height: 0.5rem;\n}\n\n:not(table)::-webkit-scrollbar-track {\n  background-color: transparent;\n}\n\n:not(table)::-webkit-scrollbar-thumb {\n  border-radius: 9999px;\n  background-color: hsl(var(--border));\n  border-width: 1px;\n  border-style: solid;\n  border-color: transparent;\n  background-clip: padding-box;\n}\n\n.setting-anchor {\n  font-size: 0.875rem;\n  width: 100%;\n  padding-left: 1rem;\n  padding-right: 1rem;\n  padding-top: 0.5rem;\n  padding-bottom: 0.5rem;\n  border-radius: 0.375rem;\n  cursor: pointer;\n  display: flex;\n  gap: 0.5rem;\n  align-items: center;\n}\n\n.setting-anchor:hover {\n  background-color: hsl(var(--accent));\n}\n\n.setting-anchor svg {\n  width: 1rem;\n  height: 1rem;\n}\n\n.search-highlight i {\n  background-color: hsl(var(--secondary-foreground));\n  color: hsl(var(--secondary));\n}\n\n.setting-input {\n  padding-left: 0.25rem;\n  padding-right: 0.25rem;\n  height: 1.25rem;\n  min-height: 1.25rem;\n  padding-top: 0;\n  padding-bottom: 0;\n  border-radius: 0.125rem;\n  border-style: none;\n  outline-style: none;\n  box-shadow: none;\n}\n\n.setting-select {\n  height: 1.5rem;\n  min-height: 1.5rem;\n}\n\nol {\n  list-style-type: decimal;\n}\n\n.scrollbar-hide {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n}\n\n.scrollbar-hide::-webkit-scrollbar {\n  display: none;\n}\n\n/* Tab scrollbar - absolute positioned, doesn't take height */\n.tab-scrollbar {\n  position: relative;\n  scrollbar-width: none;\n  -ms-overflow-style: none;\n}\n\n.tab-scrollbar::-webkit-scrollbar {\n  display: none;\n}\n\n/* Custom scrollbar using pseudo element */\n.tab-scrollbar-wrapper {\n  position: relative;\n}\n\n.tab-scrollbar-track {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  height: 2px;\n  opacity: 0;\n  transition: opacity 0.2s;\n  pointer-events: none;\n}\n\n.tab-scrollbar-wrapper:hover .tab-scrollbar-track {\n  opacity: 1;\n}\n\n.tab-scrollbar-thumb {\n  position: absolute;\n  height: 100%;\n  background: hsl(var(--muted-foreground) / 0.4);\n  border-radius: 2px;\n  min-width: 20px;\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n/* Tiptap 搜索替换高亮样式 */\n.search-result {\n  background-color: #ffeb3b !important;\n  color: #000 !important;\n  padding: 0 2px !important;\n  border-radius: 2px !important;\n  text-decoration: none !important;\n}\n\n.search-result.search-result-current,\n.search-result.is-current {\n  background-color: #ff9800 !important;\n  color: #fff !important;\n}\n\n/* 暗色模式适配 */\n.dark .search-result {\n  background-color: #f59e0b !important;\n  color: #000 !important;\n}\n\n.dark .search-result.search-result-current,\n.dark .search-result.is-current {\n  background-color: #ea580c !important;\n  color: #fff !important;\n}\n"
  },
  {
    "path": "src/app/layout.tsx",
    "content": "'use client'\nimport { Toaster } from \"@/components/ui/toaster\"\nimport \"./globals.css\";\nimport 'react-photo-view/dist/react-photo-view.css';\nimport { Suspense, useEffect } from \"react\";\nimport { NextIntlProvider } from \"@/components/providers/NextIntlProvider\";\nimport Script from \"next/script\";\nimport { getSyncPushQueue } from \"@/lib/sync/sync-push-queue\";\nimport { ConsoleFilter } from \"@/components/console-filter\";\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  // 初始化同步推送队列\n  useEffect(() => {\n    getSyncPushQueue()\n  }, [])\n\n  return (\n    <>\n      <html lang=\"en\" suppressHydrationWarning>\n        <head>\n          {/* 移动端视口设置 */}\n          <meta\n            name=\"viewport\"\n            content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, height=device-height\"\n          />\n          <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n          <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n          <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n          {/* Define isSpace function globally to fix markdown-it issues with Next.js + Turbopack\n          https://github.com/markdown-it/markdown-it/issues/1082#issuecomment-2749656365 */}\n          <Script id=\"markdown-it-fix\" strategy=\"beforeInteractive\">\n            {`\n              if (typeof window !== 'undefined' && typeof window.isSpace === 'undefined') {\n                window.isSpace = function(code) {\n                  return code === 0x20 || code === 0x09 || code === 0x0A || code === 0x0B || code === 0x0C || code === 0x0D;\n                };\n              }\n            `}\n          </Script>\n        </head>\n        <body suppressHydrationWarning>\n          <ConsoleFilter />\n          <Suspense>\n            <NextIntlProvider>\n              {children}\n            </NextIntlProvider>\n          </Suspense>\n          <Toaster />\n        </body>\n      </html>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/chat-attachments-drawer.tsx",
    "content": "\"use client\"\n\nimport { MessageCirclePlus, ImageIcon, Camera, AtSign } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { useTranslations } from \"next-intl\"\nimport { useState } from \"react\"\nimport { FileSelector } from \"@/app/core/main/chat/file-selector\"\nimport { MarkdownFile } from \"@/lib/files\"\nimport useSettingStore from \"@/stores/setting\"\nimport useChatStore from \"@/stores/chat\"\n\ninterface ChatAttachmentsDrawerProps {\n  onImageSelect: () => void\n  onCameraOpen: () => void\n  onFileLink: (file: MarkdownFile) => void\n}\n\nexport function ChatAttachmentsDrawer({\n  onImageSelect,\n  onCameraOpen,\n  onFileLink,\n}: ChatAttachmentsDrawerProps) {\n  const t = useTranslations('mobile.chat.drawer')\n  const [showFileSelector, setShowFileSelector] = useState(false)\n  const { primaryModel } = useSettingStore()\n  const { loading } = useChatStore()\n\n  return (\n    <>\n      <Drawer>\n        <DrawerTrigger asChild>\n          <TooltipButton\n            variant=\"ghost\"\n            size=\"icon\"\n            icon={<MessageCirclePlus className=\"size-4\" />}\n            tooltipText={t('attachments.title')}\n            side=\"bottom\"\n          />\n        </DrawerTrigger>\n        <DrawerContent className=\"max-h-[85vh]\">\n          <DrawerTitle className=\"sr-only\">\n            {t('attachments.title')}\n          </DrawerTitle>\n          <div className=\"p-4 overflow-auto\">\n            <div className=\"grid grid-cols-3 gap-2\">\n              <Button\n                variant=\"outline\"\n                className=\"flex flex-col items-center justify-center p-3 gap-1 h-auto\"\n                onClick={onImageSelect}\n              >\n                <ImageIcon className=\"size-4\" aria-hidden=\"true\" />\n                <span className=\"text-xs\">{t('attachments.gallery')}</span>\n              </Button>\n              \n              <Button\n                variant=\"outline\"\n                className=\"flex flex-col items-center justify-center p-3 gap-1 h-auto\"\n                onClick={onCameraOpen}\n              >\n                <Camera className=\"size-4\" />\n                <span className=\"text-xs\">{t('attachments.camera')}</span>\n              </Button>\n              \n              <DrawerClose asChild>\n                <Button\n                  variant=\"outline\"\n                  className=\"flex flex-col items-center justify-center p-3 gap-1 h-auto\"\n                  disabled={!primaryModel || loading}\n                  onClick={() => setShowFileSelector(true)}\n                >\n                  <AtSign className=\"size-4\" />\n                  <span className=\"text-xs\">{t('attachments.linkNote')}</span>\n                </Button>\n              </DrawerClose>\n            </div>\n          </div>\n        </DrawerContent>\n      </Drawer>\n\n      {showFileSelector && (\n        <FileSelector\n          isOpen={showFileSelector}\n          onClose={() => setShowFileSelector(false)}\n          onFileSelect={(file) => {\n            onFileLink(file)\n            setShowFileSelector(false)\n          }}\n        />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/chat-settings-drawer.tsx",
    "content": "\"use client\"\n\nimport { BotMessageSquare } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { ModelSelector } from \"./model-selector\"\nimport { PromptSelector } from \"./prompt-selector\"\nimport { ClipboardToggle } from \"./clipboard-toggle\"\nimport { useTranslations } from \"next-intl\"\n\nexport function ChatSettingsDrawer() {\n  const t = useTranslations('mobile.chat.drawer')\n\n  return (\n    <Drawer>\n      <DrawerTrigger asChild>\n        <TooltipButton\n          variant=\"ghost\"\n          size=\"icon\"\n          icon={<BotMessageSquare className=\"size-4\" />}\n          tooltipText={t('settings.title')}\n          side=\"bottom\"\n        />\n      </DrawerTrigger>\n      <DrawerContent className=\"max-h-[85vh]\">\n        <DrawerHeader>\n          <DrawerTitle>{t('settings.title')}</DrawerTitle>\n        </DrawerHeader>\n        <div className=\"p-4 overflow-auto\">\n          <div className=\"divide-y\">\n            <div className=\"h-16 flex items-center w-full\">\n              <ModelSelector />\n            </div>\n            <div className=\"h-16 flex items-center w-full\">\n              <PromptSelector />\n            </div>\n            <div className=\"h-16 flex items-center w-full\">\n              <ClipboardToggle />\n            </div>\n          </div>\n        </div>\n      </DrawerContent>\n    </Drawer>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/chat-tools-drawer.tsx",
    "content": "\"use client\"\n\nimport { ToolCase } from \"lucide-react\"\nimport { TooltipButton } from \"@/components/tooltip-button\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { RagToggle } from \"./rag-toggle\"\nimport { McpSelector } from \"./mcp-selector\"\nimport { ModelSelector } from \"./model-selector\"\nimport { PromptSelector } from \"./prompt-selector\"\nimport { useTranslations } from \"next-intl\"\n\nexport function ChatToolsDrawer() {\n  const t = useTranslations('mobile.chat.drawer')\n\n  return (\n    <Drawer>\n      <DrawerTrigger asChild>\n        <TooltipButton\n          variant=\"ghost\"\n          size=\"icon\"\n          icon={<ToolCase className=\"size-4\" />}\n          tooltipText={t('tools.title')}\n          side=\"bottom\"\n        />\n      </DrawerTrigger>\n      <DrawerContent className=\"max-h-[85vh]\">\n        <DrawerHeader>\n          <DrawerTitle>{t('tools.title')}</DrawerTitle>\n        </DrawerHeader>\n        <div className=\"p-4 overflow-auto\">\n          <div className=\"divide-y\">\n            <div className=\"h-16 flex items-center w-full\">\n              <ModelSelector />\n            </div>\n            <div className=\"h-16 flex items-center w-full\">\n              <PromptSelector />\n            </div>\n            <div className=\"py-2\">\n              <McpSelector />\n            </div>\n            <div className=\"h-16 flex items-center w-full\">\n              <RagToggle />\n            </div>\n          </div>\n        </div>\n      </DrawerContent>\n    </Drawer>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/clear-chat.tsx",
    "content": "\"use client\"\n\nimport React from \"react\"\nimport { Eraser } from \"lucide-react\"\nimport useChatStore from \"@/stores/chat\"\nimport useTagStore from \"@/stores/tag\"\nimport { useTranslations } from \"next-intl\"\n\nexport function MobileClearChat() {\n  const { clearChats } = useChatStore()\n  const { currentTagId } = useTagStore()\n  const t = useTranslations('mobile.chat.drawer.tools')\n\n  function clearHandler() {\n    clearChats(currentTagId)\n  }\n\n  return (\n    <div className=\"h-16 flex items-center justify-between w-full\">\n      <div className=\"flex items-center gap-3\">\n        <Eraser className=\"size-5 text-muted-foreground\" />\n        <div className=\"font-medium\">{t('clearChat')}</div>\n      </div>\n      <button\n        onClick={clearHandler}\n        className=\"px-3 py-1 text-sm bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 transition-colors\"\n      >\n        {t('clear')}\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/clear-context.tsx",
    "content": "\"use client\"\n\nimport React from \"react\"\nimport { AlignVerticalJustifyCenter } from \"lucide-react\"\nimport useChatStore from \"@/stores/chat\"\nimport useTagStore from \"@/stores/tag\"\nimport { useTranslations } from \"next-intl\"\n\nexport function MobileClearContext() {\n  const { insert } = useChatStore()\n  const { currentTagId } = useTagStore()\n  const t = useTranslations('mobile.chat.drawer.tools')\n\n  const handleClearContext = async () => {\n    // 插入一条系统消息，表示清除上下文\n    await insert({\n      tagId: currentTagId,\n      role: 'system',\n      content: '上下文已清除，之后的对话将只携带此消息之后的内容。',\n      type: 'clear',\n      inserted: true,\n      image: undefined,\n    })\n  }\n\n  return (\n    <div className=\"h-16 flex items-center justify-between w-full\">\n      <div className=\"flex items-center gap-3\">\n        <AlignVerticalJustifyCenter className=\"size-5 text-muted-foreground\" />\n        <div className=\"font-medium\">{t('clearContext')}</div>\n      </div>\n      <button\n        onClick={handleClearContext}\n        className=\"px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors\"\n      >\n        {t('clear')}\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/clipboard-toggle.tsx",
    "content": "\"use client\"\n\nimport * as React from 'react'\nimport { useEffect, useState } from 'react'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { useTranslations } from 'next-intl'\nimport { Clipboard } from 'lucide-react'\nimport { Label } from '@/components/ui/label'\nimport { Switch } from '@/components/ui/switch'\n\nexport function ClipboardToggle() {\n  const t = useTranslations('record.chat.input')\n  const [isEnabled, setIsEnabled] = useState(true)\n  \n  async function initClipboardMonitor() {\n    try {\n      const store = await Store.load('store.json')\n      const enabled = await store.get<boolean>('clipboardMonitorEnabled')\n      if (enabled !== null && enabled !== undefined) {\n        setIsEnabled(enabled)\n      }\n    } catch (error) {\n      console.error('Failed to initialize clipboard monitor:', error)\n    }\n  }\n\n  async function toggleClipboardMonitor(enabled: boolean) {\n    setIsEnabled(enabled)\n    try {\n      const store = await Store.load('store.json')\n      await store.set('clipboardMonitorEnabled', enabled)\n      await store.save()\n    } catch (error) {\n      console.error('Failed to save clipboard monitor state:', error)\n    }\n  }\n\n  useEffect(() => {\n    initClipboardMonitor()\n  }, [])\n\n  return (\n    <div className=\"flex items-center justify-between w-full\">\n      <div className=\"flex items-center gap-2\">\n        <Clipboard className=\"size-4\" />\n        <Label className=\"text-sm font-medium\">{t('clipboardMonitor.enable')}</Label>\n      </div>\n      <Switch\n        checked={isEnabled}\n        onCheckedChange={toggleClipboardMonitor}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/mcp-selector.tsx",
    "content": "\"use client\"\n\nimport * as React from 'react'\nimport { useEffect, useState } from 'react'\nimport { Server, PlugZap, Plug, ChevronRight } from 'lucide-react'\nimport { useMcpStore } from '@/stores/mcp'\nimport { useTranslations } from 'next-intl'\nimport { Label } from '@/components/ui/label'\nimport { Badge } from '@/components/ui/badge'\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { cn } from '@/lib/utils'\nimport { Switch } from '@/components/ui/switch'\n\nfunction McpListContent() {\n  const t = useTranslations('mcp')\n  const { servers, selectedServerIds, toggleServerSelection, serverStates } = useMcpStore()\n\n  const enabledServers = servers.filter(s => s.enabled)\n\n  return (\n    <div className=\"space-y-1\">\n      {enabledServers.length === 0 ? (\n        <div className=\"px-2 py-6 text-center text-sm text-muted-foreground\">\n          {t('noServersFound')}\n        </div>\n      ) : (\n        enabledServers.map((server) => {\n          const state = serverStates.get(server.id)\n          const status = state?.status || 'disconnected'\n          const toolCount = state?.tools?.length || 0\n          const isSelected = selectedServerIds.includes(server.id)\n\n          return (\n            <button\n              key={server.id}\n              onClick={() => toggleServerSelection(server.id)}\n              className={cn(\n                \"w-full flex items-center justify-between gap-3 px-3 py-3 rounded-lg text-left transition-colors\",\n                isSelected\n                  ? \"bg-accent\"\n                  : \"hover:bg-muted/50\"\n              )}\n            >\n              <div className=\"flex flex-col gap-1.5 flex-1 min-w-0\">\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"font-medium text-sm truncate\">{server.name}</span>\n                  <Badge variant=\"outline\" className=\"text-[10px] px-1 py-0 h-4 shrink-0\">\n                    {server.type}\n                  </Badge>\n                </div>\n                <div className=\"flex items-center gap-2 text-xs\">\n                  {status === 'connected' ? (\n                    <div className=\"flex items-center gap-1 text-green-600 dark:text-green-400\">\n                      <PlugZap className=\"size-3\" />\n                      <span>{toolCount} {t('tools')}</span>\n                    </div>\n                  ) : status === 'connecting' ? (\n                    <div className=\"flex items-center gap-1 text-yellow-600 dark:text-yellow-400\">\n                      <Plug className=\"size-3 animate-pulse\" />\n                      <span>{t('connecting')}</span>\n                    </div>\n                  ) : (\n                    <div className=\"flex items-center gap-1 text-muted-foreground\">\n                      <Plug className=\"size-3\" />\n                      <span>{t('disconnected')}</span>\n                    </div>\n                  )}\n                </div>\n              </div>\n              <div\n                className=\"shrink-0\"\n                onClick={(event) => event.stopPropagation()}\n                onPointerDown={(event) => event.stopPropagation()}\n              >\n                <Switch\n                  checked={isSelected}\n                  aria-label={`${t('selectServers')}: ${server.name}`}\n                  onCheckedChange={() => toggleServerSelection(server.id)}\n                />\n              </div>\n            </button>\n          )\n        })\n      )}\n    </div>\n  )\n}\n\nexport function McpSelector() {\n  const t = useTranslations('mcp')\n  const { servers, selectedServerIds, initMcpData } = useMcpStore()\n  const [open, setOpen] = useState(false)\n\n  useEffect(() => {\n    initMcpData()\n  }, [])\n\n  const enabledServers = servers.filter(s => s.enabled)\n  const selectedServers = enabledServers.filter(s => selectedServerIds.includes(s.id))\n\n  return (\n    <>\n      <button\n        onClick={() => setOpen(true)}\n        className=\"h-16 flex items-center justify-between w-full px-0\"\n      >\n        <div className=\"flex items-center gap-2\">\n          <Server className=\"size-4\" />\n          <Label className=\"text-sm font-medium\">{t('selectServers')}</Label>\n          {selectedServerIds.length > 0 && (\n            <Badge variant=\"secondary\" className=\"text-xs\">\n              {selectedServerIds.length}\n            </Badge>\n          )}\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-sm text-muted-foreground truncate max-w-40\">\n            {selectedServers.length > 0\n              ? selectedServers.map(s => s.name).join(', ')\n              : t('searchServers')\n            }\n          </span>\n          <ChevronRight className=\"size-4 text-muted-foreground shrink-0\" />\n        </div>\n      </button>\n\n      <Drawer open={open} onOpenChange={setOpen}>\n        <DrawerContent className=\"max-h-[70vh]\">\n          <DrawerHeader>\n            <DrawerTitle>{t('selectServers')}</DrawerTitle>\n          </DrawerHeader>\n          <div className=\"p-4 overflow-auto\">\n            <McpListContent />\n          </div>\n        </DrawerContent>\n      </Drawer>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/mobile-chat-header.tsx",
    "content": "\"use client\"\n\nimport { useMemo, useState } from \"react\"\nimport { History, MessageSquarePlus, Search, Trash2 } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport dayjs from \"dayjs\"\nimport relativeTime from \"dayjs/plugin/relativeTime\"\nimport \"dayjs/locale/zh-cn\"\nimport \"dayjs/locale/en\"\nimport useChatStore from \"@/stores/chat\"\nimport useSettingStore from \"@/stores/setting\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n  DrawerTrigger,\n} from \"@/components/ui/drawer\"\nimport { SearchDialog } from \"@/components/search-dialog\"\n\ndayjs.extend(relativeTime)\n\nfunction formatRelativeTime(timestamp: number, locale: string): string {\n  const dayjsLocale = locale === \"en\" ? \"en\" : \"zh-cn\"\n  return dayjs(timestamp).locale(dayjsLocale).fromNow()\n}\n\nexport function MobileChatHeader() {\n  const {\n    startNewConversation,\n    conversations,\n    currentConversationId,\n    switchConversation,\n    deleteConversation,\n    loading,\n  } = useChatStore()\n  const { language } = useSettingStore()\n  const tEmpty = useTranslations(\"record.chat.empty\")\n  const tInput = useTranslations(\"record.chat.input\")\n  const tSearch = useTranslations(\"search\")\n\n  const [drawerOpen, setDrawerOpen] = useState(false)\n  const [searchOpen, setSearchOpen] = useState(false)\n  const [searchQuery, setSearchQuery] = useState(\"\")\n\n  const hasCurrentMessages = conversations.some(\n    (conversation) =>\n      conversation.id === currentConversationId && conversation.messageCount > 0\n  )\n  const disableNewChat = !hasCurrentMessages || loading\n\n  const filteredConversations = useMemo(() => {\n    return conversations\n      .filter((conversation) => conversation.messageCount > 0)\n      .filter((conversation) =>\n        conversation.title.toLowerCase().includes(searchQuery.toLowerCase())\n      )\n      .sort((a, b) => {\n        if (a.isPinned && !b.isPinned) return -1\n        if (!a.isPinned && b.isPinned) return 1\n        return b.updatedAt - a.updatedAt\n      })\n  }, [conversations, searchQuery])\n\n  return (\n    <>\n      <header className=\"mobile-page-header w-full border-b px-2 flex items-center gap-2 bg-background\">\n        <button\n          type=\"button\"\n          aria-label={tSearch(\"placeholder\")}\n          onClick={() => setSearchOpen(true)}\n          className=\"flex h-9 min-w-0 flex-1 items-center rounded-md border bg-muted/30 px-3 text-left\"\n        >\n          <Search className=\"size-4 shrink-0 text-muted-foreground\" />\n          <span className=\"ml-2 truncate text-sm text-muted-foreground\">\n            {tSearch(\"placeholder\")}\n          </span>\n        </button>\n\n        <div className=\"flex items-center shrink-0\">\n          <Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>\n            <DrawerTrigger asChild>\n              <Button variant=\"ghost\" size=\"icon\" aria-label={tEmpty(\"conversationHistory\")}>\n                <History className=\"size-4\" />\n              </Button>\n            </DrawerTrigger>\n            <DrawerContent className=\"max-h-[85vh]\">\n              <DrawerHeader className=\"pb-2\">\n                <DrawerTitle>{tEmpty(\"conversationHistory\")}</DrawerTitle>\n              </DrawerHeader>\n              <div className=\"px-4 pb-4\">\n                <div className=\"relative mb-3\">\n                  <Search className=\"absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground\" />\n                  <Input\n                    value={searchQuery}\n                    onChange={(event) => setSearchQuery(event.target.value)}\n                    placeholder={tEmpty(\"searchPlaceholder\")}\n                    className=\"pl-8\"\n                  />\n                </div>\n                <div className=\"max-h-[56vh] overflow-y-auto\">\n                  {filteredConversations.length === 0 ? (\n                    <div className=\"text-sm text-muted-foreground py-8 text-center\">\n                      {searchQuery\n                        ? tEmpty(\"noMatchingConversations\")\n                        : tEmpty(\"noConversationHistory\")}\n                    </div>\n                  ) : (\n                    filteredConversations.map((conversation) => (\n                      <button\n                        key={conversation.id}\n                        className=\"w-full text-left p-3 rounded-lg border mb-2 active:bg-accent transition-colors\"\n                        onClick={() => {\n                          switchConversation(conversation.id)\n                          setDrawerOpen(false)\n                        }}\n                      >\n                        <div className=\"flex items-start justify-between gap-2\">\n                          <div className=\"min-w-0\">\n                            <p className=\"text-sm font-medium truncate\">{conversation.title}</p>\n                            <p className=\"text-xs text-muted-foreground mt-1\">\n                              {formatRelativeTime(conversation.updatedAt, language)}\n                            </p>\n                          </div>\n                          <Button\n                            type=\"button\"\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"shrink-0 size-8 text-muted-foreground active:text-destructive\"\n                            onClick={(event) => {\n                              event.stopPropagation()\n                              deleteConversation(conversation.id)\n                            }}\n                            aria-label={tEmpty(\"deleteConversation\")}\n                          >\n                            <Trash2 className=\"size-4\" />\n                          </Button>\n                        </div>\n                      </button>\n                    ))\n                  )}\n                </div>\n              </div>\n            </DrawerContent>\n          </Drawer>\n\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            aria-label={tInput(\"newChat\")}\n            onClick={() => startNewConversation()}\n            disabled={disableNewChat}\n          >\n            <MessageSquarePlus className=\"size-4\" />\n          </Button>\n        </div>\n      </header>\n      <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/model-selector.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useEffect, useState } from \"react\"\nimport { ModelConfig } from \"@/app/core/setting/config\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport useSettingStore from \"@/stores/setting\"\nimport { BotMessageSquare, BotOff, Check, ChevronRight } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { cn } from \"@/lib/utils\"\n\ninterface GroupedModel {\n  configKey: string\n  configTitle: string\n  model: ModelConfig\n}\n\nfunction ModelListContent({\n  groupedByConfig,\n  primaryModel,\n  onSelect,\n}: {\n  groupedByConfig: Record<string, GroupedModel[]>\n  primaryModel?: string\n  onSelect: (modelId: string) => void\n}) {\n  return (\n    <div className=\"space-y-4\">\n      {Object.entries(groupedByConfig).map(([configTitle, models]) => (\n        <div key={configTitle} className=\"space-y-1\">\n          <div className=\"px-2 text-xs font-medium text-muted-foreground\">\n            {configTitle}\n          </div>\n          {models.map((item) => {\n            const isSelected = primaryModel === item.model.id\n\n            return (\n              <button\n                key={item.model.id}\n                onClick={() => onSelect(item.model.id)}\n                className={cn(\n                  \"w-full flex items-center justify-between gap-3 px-3 py-3 rounded-lg text-left transition-colors\",\n                  isSelected ? \"bg-accent\" : \"hover:bg-muted/50\"\n                )}\n              >\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"font-medium text-sm truncate\">{item.model.model}</div>\n                </div>\n                <div\n                  className={cn(\n                    \"flex items-center justify-center w-5 h-5 rounded border transition-colors shrink-0\",\n                    isSelected\n                      ? \"bg-primary border-primary text-primary-foreground\"\n                      : \"border-muted-foreground/30\"\n                  )}\n                >\n                  {isSelected && <Check className=\"size-3.5\" />}\n                </div>\n              </button>\n            )\n          })}\n        </div>\n      ))}\n    </div>\n  )\n}\n\nexport function ModelSelector() {\n  const [groupedModels, setGroupedModels] = useState<GroupedModel[]>([])\n  const [open, setOpen] = useState(false)\n  const { primaryModel, setPrimaryModel, aiModelList, initSettingData } = useSettingStore()\n  const t = useTranslations('record.chat.input.modelSelect')\n\n  async function modelSelectChangeHandler(modelId: string) {\n    setPrimaryModel(modelId)\n    const store = await Store.load('store.json')\n    store.set('primaryModel', modelId)\n    await store.save()\n  }\n\n  useEffect(() => {\n    initSettingData()\n  }, [])\n\n  useEffect(() => {\n    if (aiModelList && aiModelList.length > 0) {\n      const models: GroupedModel[] = []\n      \n      aiModelList.forEach(config => {\n        if (!config.baseURL) return\n        \n        if (config.models && config.models.length > 0) {\n          config.models.forEach(model => {\n            if (model.modelType === 'chat' && model.model) {\n              models.push({\n                configKey: config.key,\n                configTitle: config.title,\n                model: model\n              })\n            }\n          })\n        } else {\n          if ((config.modelType === 'chat' || !config.modelType) && config.model) {\n            models.push({\n              configKey: config.key,\n              configTitle: config.title,\n              model: {\n                id: config.key,\n                model: config.model,\n                modelType: config.modelType || 'chat',\n                temperature: config.temperature,\n                topP: config.topP,\n                voice: config.voice,\n                enableStream: config.enableStream\n              }\n            })\n          }\n        }\n      })\n      \n      setGroupedModels(models)\n    }\n  }, [aiModelList])\n\n  const groupedByConfig = groupedModels.reduce((acc, item) => {\n    if (!acc[item.configTitle]) {\n      acc[item.configTitle] = []\n    }\n    acc[item.configTitle].push(item)\n    return acc\n  }, {} as Record<string, GroupedModel[]>)\n\n  const selectedModel = groupedModels.find((item) => item.model.id === primaryModel)\n\n  return (\n    <>\n      <button\n        onClick={() => setOpen(true)}\n        className=\"h-16 flex items-center justify-between w-full px-0\"\n      >\n        <div className=\"flex items-center gap-2\">\n          {groupedModels.length > 0 ? (\n            <BotMessageSquare className=\"size-4\" />\n          ) : (\n            <BotOff className=\"size-4\" />\n          )}\n          <Label className=\"text-sm font-medium\">{t('tooltip')}</Label>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-sm text-muted-foreground truncate max-w-40\">\n            {selectedModel?.model.model || t('placeholder')}\n          </span>\n          <ChevronRight className=\"size-4 text-muted-foreground shrink-0\" />\n        </div>\n      </button>\n\n      <Drawer open={open} onOpenChange={setOpen}>\n        <DrawerContent className=\"max-h-[70vh]\">\n          <DrawerHeader>\n            <DrawerTitle>{t('tooltip')}</DrawerTitle>\n          </DrawerHeader>\n          <div className=\"p-4 overflow-auto\">\n            <ModelListContent\n              groupedByConfig={groupedByConfig}\n              primaryModel={primaryModel}\n              onSelect={async (modelId) => {\n                await modelSelectChangeHandler(modelId)\n                setOpen(false)\n              }}\n            />\n          </div>\n        </DrawerContent>\n      </Drawer>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/new-chat.tsx",
    "content": "\"use client\"\n\nimport React from \"react\"\nimport { SquareCode } from \"lucide-react\"\nimport useChatStore from \"@/stores/chat\"\nimport { useTranslations } from \"next-intl\"\n\nexport function MobileNewChat() {\n  const { startNewConversation } = useChatStore()\n  const t = useTranslations('mobile.chat.drawer.tools')\n\n  function newChatHandler() {\n    startNewConversation()\n  }\n\n  return (\n    <div className=\"h-16 flex items-center justify-between w-full\">\n      <div className=\"flex items-center gap-3\">\n        <SquareCode className=\"size-5 text-muted-foreground\" />\n        <div className=\"font-medium\">{t('newChat')}</div>\n      </div>\n      <button\n        onClick={newChatHandler}\n        className=\"px-3 py-1 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors\"\n      >\n        {t('start')}\n      </button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/prompt-selector.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useEffect, useState } from \"react\"\nimport { useTranslations } from \"next-intl\"\nimport { Drama, Check, ChevronRight } from \"lucide-react\"\nimport usePromptStore from \"@/stores/prompt\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { cn } from \"@/lib/utils\"\n\nfunction PromptListContent({\n  promptList,\n  currentPromptId,\n  onSelect,\n}: {\n  promptList: { id: string; title: string }[]\n  currentPromptId?: string\n  onSelect: (id: string) => void\n}) {\n  return (\n    <div className=\"space-y-1\">\n      {promptList.map((item) => {\n        const isSelected = currentPromptId === item.id\n\n        return (\n          <button\n            key={item.id}\n            onClick={() => onSelect(item.id)}\n            className={cn(\n              \"w-full flex items-center justify-between gap-3 px-3 py-3 rounded-lg text-left transition-colors\",\n              isSelected ? \"bg-accent\" : \"hover:bg-muted/50\"\n            )}\n          >\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"font-medium text-sm truncate\">{item.title}</div>\n            </div>\n            <div\n              className={cn(\n                \"flex items-center justify-center w-5 h-5 rounded border transition-colors shrink-0\",\n                isSelected\n                  ? \"bg-primary border-primary text-primary-foreground\"\n                  : \"border-muted-foreground/30\"\n              )}\n            >\n              {isSelected && <Check className=\"size-3.5\" />}\n            </div>\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\nexport function PromptSelector() {\n  const { promptList, currentPrompt, initPromptData, setCurrentPrompt } = usePromptStore()\n  const [open, setOpen] = useState(false)\n  const t = useTranslations('record.chat.input.promptSelect')\n\n  useEffect(() => {\n    initPromptData()\n  }, [])\n\n  async function promptSelectChangeHandler(id: string) {\n    const selectedPrompt = promptList.find(item => item.id === id)\n    if (!selectedPrompt) return\n    await setCurrentPrompt(selectedPrompt)\n  }\n\n  return (\n    <>\n      <button\n        onClick={() => setOpen(true)}\n        className=\"h-16 flex items-center justify-between w-full px-0\"\n      >\n        <div className=\"flex items-center gap-2\">\n          <Drama className=\"size-4\" />\n          <Label className=\"text-sm font-medium\">{t('tooltip')}</Label>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-sm text-muted-foreground truncate max-w-40\">\n            {currentPrompt?.title || t('tooltip')}\n          </span>\n          <ChevronRight className=\"size-4 text-muted-foreground shrink-0\" />\n        </div>\n      </button>\n\n      <Drawer open={open} onOpenChange={setOpen}>\n        <DrawerContent className=\"max-h-[70vh]\">\n          <DrawerHeader>\n            <DrawerTitle>{t('tooltip')}</DrawerTitle>\n          </DrawerHeader>\n          <div className=\"p-4 overflow-auto\">\n            <PromptListContent\n              promptList={promptList.map(({ id, title }) => ({ id, title }))}\n              currentPromptId={currentPrompt?.id}\n              onSelect={async (id) => {\n                await promptSelectChangeHandler(id)\n                setOpen(false)\n              }}\n            />\n          </div>\n        </DrawerContent>\n      </Drawer>\n    </>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/components/rag-toggle.tsx",
    "content": "\"use client\"\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Database } from 'lucide-react'\nimport useVectorStore from '@/stores/vector'\nimport { checkEmbeddingModelAvailable } from '@/lib/rag'\nimport { toast } from '@/hooks/use-toast'\nimport { Label } from '@/components/ui/label'\nimport { Switch } from '@/components/ui/switch'\n\nexport function RagToggle() {\n  const { isRagEnabled, setRagEnabled } = useVectorStore()\n  const t = useTranslations('record.chat.input')\n  const [loading, setLoading] = useState(false)\n\n  const handleToggle = async (checked: boolean) => {\n    if (!checked) {\n      await setRagEnabled(false)\n    } else {\n      setLoading(true)\n      const embeddingModelAvailable = await checkEmbeddingModelAvailable()\n      setLoading(false)\n      if (!embeddingModelAvailable) {\n        toast({\n          variant: \"destructive\",\n          description: t('rag.notSupported')\n        })\n        return\n      }\n      await setRagEnabled(true)\n    }\n  }\n\n  return (\n    <div className=\"flex items-center justify-between w-full\">\n      <div className=\"flex items-center gap-2\">\n        <Database className=\"size-4\" />\n        <Label className=\"text-sm font-medium\">{t('rag.enabled')}</Label>\n      </div>\n      <Switch\n        checked={isRagEnabled}\n        onCheckedChange={handleToggle}\n        disabled={loading}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/chat/page.tsx",
    "content": "'use client'\nimport ChatContent from '@/app/core/main/chat/chat-content'\nimport { ClipboardListener } from '@/app/core/main/chat/clipboard-listener'\nimport { ChatInput } from '@/app/core/main/chat/chat-input'\nimport { MobileChatHeader } from './components/mobile-chat-header'\n\nexport default function Chat() {\n  return (\n    <div id=\"mobile-chat\" className=\"flex flex-col flex-1 w-full\">\n      <MobileChatHeader />\n      <ChatContent />\n      <ClipboardListener />\n      <div className=\"px-1 pb-1\">\n        <ChatInput />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/layout.tsx",
    "content": "'use client'\n\nimport { ThemeProvider } from \"@/components/theme-provider\"\nimport useSettingStore from \"@/stores/setting\"\nimport { useEffect } from \"react\"\nimport { applyThemeColors } from \"@/lib/theme-utils\"\nimport { initAllDatabases } from \"@/db\"\nimport dayjs from \"dayjs\"\nimport zh from \"dayjs/locale/zh-cn\";\nimport en from \"dayjs/locale/en\";\nimport { useI18n } from \"@/hooks/useI18n\"\nimport useVectorStore from \"@/stores/vector\"\nimport { AppFootbar } from \"@/components/app-footbar\"\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport './mobile-styles.css'\nimport useImageStore from \"@/stores/imageHosting\";\nimport { initMcp } from \"@/lib/mcp/init\"\nimport { reportAppStart } from \"@/lib/event-report\"\nimport { MobileStatusBar } from \"@/components/mobile-statusbar\"\nimport { TextSizeProvider } from \"@/contexts/text-size-context\"\nimport { SyncConfirmDialog } from \"@/components/sync-confirm-dialog\"\nimport { ControlText } from \"@/app/core/main/mark/control-text\"\nimport { ControlRecording } from \"@/app/core/main/mark/control-recording\"\nimport { ControlImage } from \"@/app/core/main/mark/control-image\"\nimport { ControlLink } from \"@/app/core/main/mark/control-link\"\nimport { ControlFile } from \"@/app/core/main/mark/control-file\"\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  const { initSettingData, customThemeColors } = useSettingStore()\n  const { initMainHosting } = useImageStore()\n  const { currentLocale } = useI18n()\n  useEffect(() => {\n    initSettingData()\n    initMainHosting()\n    initAllDatabases()\n    initMcp()\n    // 上报应用启动事件\n    reportAppStart()\n  }, [])\n\n  const { initVectorDb } = useVectorStore()\n  \n  // 初始化向量数据库\n  useEffect(() => {\n    initVectorDb()\n  }, [])\n\n  useEffect(() => {\n    switch (currentLocale) {\n      case 'zh':\n        dayjs.locale(zh);\n        break;\n      case 'en':\n        dayjs.locale(en);\n        break;\n      default:\n        break;\n    }\n  }, [currentLocale])\n\n  // 应用自定义主题颜色\n  useEffect(() => {\n    applyThemeColors(customThemeColors)\n  }, [customThemeColors])\n\n  return (\n    <ThemeProvider\n      attribute=\"class\"\n      defaultTheme=\"system\"\n      enableSystem\n      disableTransitionOnChange\n    >\n      <TextSizeProvider>\n        <MobileStatusBar />\n        <TooltipProvider>\n          <div className=\"flex flex-col h-full\">\n            <main className=\"flex flex-1 w-full overflow-hidden\">\n              {children}\n            </main>\n            <AppFootbar />\n          </div>\n          {/* 隐藏的记录工具组件，用于监听事件 */}\n          <div className=\"absolute opacity-0 pointer-events-none -z-50\">\n            <ControlText />\n            <ControlRecording />\n            <ControlImage />\n            <ControlLink />\n            <ControlFile />\n          </div>\n        </TooltipProvider>\n        <SyncConfirmDialog />\n      </TextSizeProvider>\n    </ThemeProvider>\n  );\n}\n"
  },
  {
    "path": "src/app/mobile/mobile-styles.css",
    "content": "/* Prevent zoom on input focus */\ninput, textarea, select, [contenteditable] {\n  font-size: 16px !important; /* Prevent iOS zoom */\n  touch-action: manipulation; /* Disable double-tap zoom */\n}\n\n/* Fix for iOS viewport when keyboard appears */\n@supports (-webkit-touch-callout: none) {\n  html, body {\n    height: 100%;\n    position: fixed;\n    overflow: hidden;\n    -webkit-overflow-scrolling: touch;\n    padding-top: env(safe-area-inset-top);\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n}\n\n/* Fix for Android viewport when keyboard appears */\n@media screen and (-webkit-min-device-pixel-ratio:0) {\n  html, body {\n    position: fixed;\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    padding-top: env(safe-area-inset-top);\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n}\n\n/* Ensure input elements are properly sized on mobile */\ninput, textarea, select {\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n}\n\n/* Keep top-level mobile page headers visually consistent */\n.mobile-page-header {\n  height: 3rem;\n  min-height: 3rem;\n  max-height: 3rem;\n}\n"
  },
  {
    "path": "src/app/mobile/record/mobile-mark-header.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useEffect } from 'react'\nimport { clearTrash, initMarksDb, restoreMarks } from '@/db/marks'\nimport { Button } from '@/components/ui/button'\nimport { FileText, Trash2, XCircle, RotateCcw } from 'lucide-react'\nimport useMarkStore from '@/stores/mark'\nimport { confirm } from '@tauri-apps/plugin-dialog'\n\nexport function MobileMarkHeader() {\n  const markT = useTranslations('record.mark')\n  const recordT = useTranslations('record')\n  const { trashState, setTrashState, fetchAllTrashMarks, fetchAllMarks, marks, allMarks, setMarks } = useMarkStore()\n\n  useEffect(() => {\n    initMarksDb()\n  }, [])\n\n  useEffect(() => {\n    if (trashState) {\n      fetchAllTrashMarks()\n    } else {\n      fetchAllMarks()\n    }\n  }, [trashState, fetchAllTrashMarks, fetchAllMarks])\n\n  async function handleClearTrash() {\n    const accepted = await confirm(recordT('trash.confirm'), {\n      title: recordT('trash.title'),\n      kind: 'warning',\n    })\n    if (!accepted) return\n    await clearTrash()\n    setMarks([])\n    await fetchAllTrashMarks()\n  }\n\n  async function handleRestoreAll() {\n    if (marks.length === 0) return\n    await restoreMarks(marks.map((item) => item.id))\n    setMarks([])\n    await fetchAllTrashMarks()\n  }\n\n  return (\n    <div className=\"mobile-page-header flex justify-between items-center border-b px-3\">\n      {/* 左侧：记录标题和数量 */}\n      <div className=\"flex items-center gap-2\">\n        <FileText className=\"h-4 w-4\" />\n        <span className=\"font-medium text-sm\">\n          {markT('list.title')} ({trashState ? marks.length : allMarks.length})\n        </span>\n      </div>\n\n      {/* 右侧：回收站按钮 / 关闭回收站 */}\n      <div className=\"flex items-center gap-1\">\n        {trashState ? (\n          <>\n            {marks.length > 0 && (\n              <>\n                <Button variant=\"outline\" size=\"sm\" className=\"h-8 px-2\" onClick={handleRestoreAll}>\n                  <RotateCcw className=\"mr-1 size-3.5\" />\n                  {recordT('trash.restoreAll')}\n                </Button>\n                <Button variant=\"outline\" size=\"sm\" className=\"h-8 px-2\" onClick={handleClearTrash}>\n                  {recordT('trash.empty')}\n                </Button>\n              </>\n            )}\n            <Button variant=\"ghost\" size=\"icon\" className=\"h-11 w-11\" onClick={() => setTrashState(false)}>\n              <XCircle />\n            </Button>\n          </>\n        ) : (\n          <Button variant=\"outline\" size=\"sm\" className=\"h-8 px-2.5 text-xs\" onClick={() => setTrashState(true)}>\n            <Trash2 className=\"mr-1 size-4\" />\n            {markT('toolbar.trash')}\n          </Button>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/record/mobile-record-stream.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport dayjs from 'dayjs'\nimport { useTranslations } from 'next-intl'\nimport { Input } from '@/components/ui/input'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Textarea } from '@/components/ui/textarea'\nimport { LocalImage } from '@/components/local-image'\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'\nimport { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Trash2, MoveRight, CheckSquare, XSquare, Filter, Plus, ListChecks, RotateCcw, Search } from 'lucide-react'\nimport { filterMarks } from '@/app/core/main/mark/mark-filters.mjs'\nimport { getMarkTypeChipClasses, MARK_TYPE_OPTIONS } from '@/app/core/main/mark/mark-type-meta'\nimport useMarkStore, { RecordTimePreset } from '@/stores/mark'\nimport useTagStore from '@/stores/tag'\nimport { delMark, delMarkForever, Mark, restoreMark, updateMark as updateMarkDb } from '@/db/marks'\nimport { insertTag } from '@/db/tags'\nimport { cn } from '@/lib/utils'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\n\nconst TIME_OPTIONS: RecordTimePreset[] = ['all', 'today', 'last7Days', 'last30Days']\n\nfunction getMarkPreview(mark: Mark): string {\n  if (mark.type === 'text') return mark.content?.trim() || mark.desc?.trim() || ''\n  if (mark.type === 'image' || mark.type === 'scan') return mark.desc?.trim() || mark.content?.trim() || ''\n  if (mark.type === 'link') return mark.url || mark.desc || ''\n  return mark.desc?.trim() || mark.content?.trim() || mark.url || ''\n}\n\nexport function MobileRecordStream() {\n  const t = useTranslations()\n  const {\n    trashState,\n    marks,\n    allMarks,\n    queues,\n    fetchAllMarks,\n    fetchAllTrashMarks,\n    recordFilters,\n    setRecordSearch,\n    toggleRecordType,\n    setRecordTimePreset,\n    setRecordTagId,\n    resetRecordFilters,\n    hasActiveRecordFilters,\n    setVisibleMarkIds,\n    initRecordFilters,\n  } = useMarkStore()\n  const { tags, fetchTags } = useTagStore()\n\n  const [multiMode, setMultiMode] = useState(false)\n  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())\n  const [createTagOpen, setCreateTagOpen] = useState(false)\n  const [typeFilterOpen, setTypeFilterOpen] = useState(false)\n  const [newTagName, setNewTagName] = useState('')\n  const [activeMark, setActiveMark] = useState<Mark | null>(null)\n  const [moveTargetMark, setMoveTargetMark] = useState<Mark | null>(null)\n  const [editDesc, setEditDesc] = useState('')\n  const [editContent, setEditContent] = useState('')\n  const [editUrl, setEditUrl] = useState('')\n  const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null)\n  const touchStartXRef = useRef(0)\n  const touchStartYRef = useRef(0)\n  const isSwipingRef = useRef(false)\n  const swipingMarkIdRef = useRef<number | null>(null)\n  const [swipedMarkId, setSwipedMarkId] = useState<number | null>(null)\n  const [swipeDeltaX, setSwipeDeltaX] = useState(0)\n\n  useEffect(() => {\n    initRecordFilters()\n  }, [initRecordFilters])\n\n  useEffect(() => {\n    fetchTags()\n  }, [fetchTags])\n\n  useEffect(() => {\n    if (trashState) {\n      fetchAllTrashMarks()\n    } else {\n      fetchAllMarks()\n    }\n  }, [trashState, fetchAllMarks, fetchAllTrashMarks])\n\n  useEffect(() => {\n    if (!multiMode) {\n      setSelectedIds(new Set())\n    }\n  }, [multiMode])\n\n  useEffect(() => {\n    if (!activeMark) return\n    setEditDesc(activeMark.type === 'text' ? (activeMark.content || '') : (activeMark.desc || ''))\n    setEditContent(activeMark.content || '')\n    setEditUrl(activeMark.url || '')\n  }, [activeMark])\n\n  useEffect(() => {\n    if (!activeMark) return\n    const hasChanges =\n      (activeMark.desc || '') !== editDesc ||\n      (activeMark.content || '') !== editContent ||\n      (activeMark.url || '') !== editUrl\n\n    if (!hasChanges) return\n\n    if (autoSaveTimerRef.current) {\n      clearTimeout(autoSaveTimerRef.current)\n    }\n\n    autoSaveTimerRef.current = setTimeout(async () => {\n      const updatedMark: Mark = {\n        ...activeMark,\n        desc: activeMark.type === 'text' ? editContent : editDesc,\n        content: editContent,\n        url: editUrl,\n      }\n      await updateMarkDb(updatedMark)\n      setActiveMark(updatedMark)\n      await refreshRecords()\n    }, 300)\n\n    return () => {\n      if (autoSaveTimerRef.current) {\n        clearTimeout(autoSaveTimerRef.current)\n      }\n    }\n  }, [activeMark, editDesc, editContent, editUrl])\n\n  // 新增记录流程会先刷新 marks（当前标签），这里同步拉取 allMarks 保持时间流实时更新\n  useEffect(() => {\n    if (!trashState) {\n      fetchAllMarks()\n    }\n  }, [marks, trashState, fetchAllMarks])\n\n  const records = trashState ? marks : allMarks\n  const tagMap = useMemo(() => new Map(tags.map((tag) => [tag.id, tag.name])), [tags])\n\n  const filteredRecords = useMemo(() => {\n    return filterMarks(records, recordFilters)\n  }, [records, recordFilters])\n\n  const groupedRecords = useMemo(() => {\n    const groups: Array<{ day: string; list: Mark[] }> = []\n    const groupMap = new Map<string, Mark[]>()\n    for (const mark of filteredRecords) {\n      const day = dayjs(mark.createdAt).format('YYYY-MM-DD')\n      if (!groupMap.has(day)) groupMap.set(day, [])\n      groupMap.get(day)!.push(mark)\n    }\n    Array.from(groupMap.keys()).forEach((day) => {\n      groups.push({ day, list: groupMap.get(day)! })\n    })\n    return groups\n  }, [filteredRecords])\n\n  useEffect(() => {\n    setVisibleMarkIds(filteredRecords.map((mark: Mark) => mark.id))\n    return () => setVisibleMarkIds([])\n  }, [filteredRecords, setVisibleMarkIds])\n\n  function getDayLabel(day: string) {\n    if (dayjs(day).isSame(dayjs(), 'day')) return t('common.today')\n    if (dayjs(day).isSame(dayjs().subtract(1, 'day'), 'day')) return t('common.yesterday')\n    return day\n  }\n\n  async function refreshRecords() {\n    if (trashState) {\n      await fetchAllTrashMarks()\n    } else {\n      await fetchAllMarks()\n    }\n  }\n\n  function toggleSelect(id: number) {\n    setSelectedIds((prev) => {\n      const next = new Set(prev)\n      if (next.has(id)) next.delete(id)\n      else next.add(id)\n      return next\n    })\n  }\n\n  async function handleDelete(mark: Mark) {\n    if (trashState) {\n      await delMarkForever(mark.id)\n    } else {\n      await delMark(mark.id)\n    }\n    await refreshRecords()\n  }\n\n  async function handleRestore(mark: Mark) {\n    await restoreMark(mark.id)\n    await refreshRecords()\n  }\n\n  async function handleMove(mark: Mark, targetTagId: number) {\n    await updateMarkDb({ ...mark, tagId: targetTagId })\n    await refreshRecords()\n  }\n\n  function getActionWidth() {\n    return 120\n  }\n\n  function handleItemTouchStart(e: React.TouchEvent, markId: number) {\n    if (multiMode) return\n    const touch = e.touches[0]\n    touchStartXRef.current = touch.clientX\n    touchStartYRef.current = touch.clientY\n    isSwipingRef.current = false\n    swipingMarkIdRef.current = markId\n    if (swipedMarkId !== markId) {\n      setSwipedMarkId(null)\n    }\n  }\n\n  function handleItemTouchMove(e: React.TouchEvent) {\n    if (multiMode || swipingMarkIdRef.current === null) return\n    const touch = e.touches[0]\n    const deltaX = touch.clientX - touchStartXRef.current\n    const deltaY = touch.clientY - touchStartYRef.current\n\n    if (!isSwipingRef.current) {\n      if (Math.abs(deltaX) < 8) return\n      if (Math.abs(deltaX) <= Math.abs(deltaY)) return\n      isSwipingRef.current = true\n    }\n\n    e.preventDefault()\n    const maxLeft = -getActionWidth()\n    const next = Math.max(maxLeft, Math.min(0, deltaX))\n    setSwipeDeltaX(next)\n  }\n\n  function handleItemTouchEnd() {\n    if (multiMode || swipingMarkIdRef.current === null) return\n    const id = swipingMarkIdRef.current\n    const maxLeft = -getActionWidth()\n    const shouldOpen = swipeDeltaX < maxLeft / 2\n    setSwipedMarkId(shouldOpen ? id : null)\n    setSwipeDeltaX(0)\n    isSwipingRef.current = false\n    swipingMarkIdRef.current = null\n  }\n\n  async function handleMoveTargetTag(targetTagId: number) {\n    if (!moveTargetMark) return\n    await handleMove(moveTargetMark, targetTagId)\n    setMoveTargetMark(null)\n    setSwipedMarkId(null)\n  }\n\n  async function handleDeleteSelected() {\n    const targets = filteredRecords.filter((item: Mark) => selectedIds.has(item.id))\n    for (const item of targets) {\n      if (trashState) {\n        await delMarkForever(item.id)\n      } else {\n        await delMark(item.id)\n      }\n    }\n    setSelectedIds(new Set())\n    await refreshRecords()\n  }\n\n  async function handleMoveSelected(targetTagId: number) {\n    const targets = filteredRecords.filter((item: Mark) => selectedIds.has(item.id))\n    for (const item of targets) {\n      await updateMarkDb({ ...item, tagId: targetTagId })\n    }\n    setSelectedIds(new Set())\n    await refreshRecords()\n  }\n\n  const selectedCount = selectedIds.size\n  const isAllSelected = filteredRecords.length > 0 && selectedIds.size === filteredRecords.length\n\n  const tagLabel = recordFilters.tagId === 'all' ? t('common.all') : (tags.find((item) => item.id === recordFilters.tagId)?.name || t('common.all'))\n\n  const selectedTypeCount = recordFilters.selectedTypes.length\n  const canMoveBetweenTags = tags.length >= 2\n  const isFilterActive = hasActiveRecordFilters()\n\n  function toggleTypeFilter(type: Mark['type']) {\n    toggleRecordType(type)\n  }\n\n  function selectAllTypes() {\n    if (recordFilters.selectedTypes.length === MARK_TYPE_OPTIONS.length) {\n      MARK_TYPE_OPTIONS.forEach((type) => {\n        if (recordFilters.selectedTypes.includes(type)) {\n          toggleRecordType(type)\n        }\n      })\n      return\n    }\n\n    MARK_TYPE_OPTIONS.forEach((type) => {\n      if (!recordFilters.selectedTypes.includes(type)) {\n        toggleRecordType(type)\n      }\n    })\n  }\n\n  async function handleCreateTag() {\n    const value = newTagName.trim()\n    if (!value) return\n    const res = await insertTag({ name: value })\n    const newTagId = Number(res.lastInsertId)\n    await fetchTags()\n    setRecordTagId(newTagId)\n    setNewTagName('')\n    setCreateTagOpen(false)\n  }\n\n  function handleResetFilters() {\n    resetRecordFilters()\n    setTypeFilterOpen(false)\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      {!trashState && (\n        <div className=\"sticky top-0 z-10 border-b bg-background px-3 pb-2 pt-2\">\n          <div className=\"flex items-center gap-2 overflow-x-auto\">\n            {!multiMode ? (\n              <>\n                <Select value={String(recordFilters.tagId)} onValueChange={(value) => setRecordTagId(value === 'all' ? 'all' : Number(value))}>\n                  <SelectTrigger className=\"h-9 min-w-0 flex-1\">\n                    <SelectValue placeholder={tagLabel} />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"all\">{t('common.all')}</SelectItem>\n                    {tags.map((tag) => (\n                      <SelectItem key={tag.id} value={String(tag.id)}>\n                        {tag.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n\n                <Button variant=\"outline\" size=\"icon\" className=\"h-9 w-9 shrink-0\" onClick={() => setCreateTagOpen(true)} title={t('record.mark.tag.newTag')}>\n                  <Plus className=\"size-4\" />\n                </Button>\n\n                <Button\n                  variant={isFilterActive ? 'secondary' : 'outline'}\n                  size=\"sm\"\n                  className=\"relative h-9 w-9 shrink-0\"\n                  title={t('record.mark.toolbar.filter.title')}\n                  onClick={() => setTypeFilterOpen(true)}\n                >\n                  <Filter className=\"size-4\" />\n                  {isFilterActive ? (\n                    <span className=\"absolute right-1 top-1 h-2 w-2 rounded-full bg-primary ring-2 ring-background\" />\n                  ) : null}\n                </Button>\n\n                <Button variant=\"outline\" size=\"icon\" className=\"h-9 w-9 shrink-0\" onClick={() => setMultiMode(true)} title={t('record.mark.toolbar.multiSelect')}>\n                  <CheckSquare className=\"size-4\" />\n                </Button>\n              </>\n            ) : (\n              <>\n                <div className=\"flex items-center gap-2\">\n                  <Button\n                    variant=\"outline\"\n                    size=\"icon\"\n                    className=\"h-9 w-9 shrink-0\"\n                    onClick={() => setSelectedIds(isAllSelected ? new Set() : new Set(filteredRecords.map((item: Mark) => item.id)))}\n                    title={t('record.mark.toolbar.selectAll')}\n                  >\n                    <ListChecks className=\"size-4\" />\n                  </Button>\n                  <DropdownMenu>\n                    <DropdownMenuTrigger asChild>\n                      <Button\n                        variant=\"outline\"\n                        size=\"icon\"\n                        className=\"h-9 w-9 shrink-0\"\n                        disabled={selectedCount === 0 || !canMoveBetweenTags}\n                        title={t('record.mark.toolbar.moveTag')}\n                      >\n                        <MoveRight className=\"size-4\" />\n                      </Button>\n                    </DropdownMenuTrigger>\n                    <DropdownMenuContent align=\"start\">\n                      {tags.map((tag) => (\n                        <DropdownMenuItem key={tag.id} onClick={() => handleMoveSelected(tag.id)}>\n                          {tag.name}\n                        </DropdownMenuItem>\n                      ))}\n                    </DropdownMenuContent>\n                  </DropdownMenu>\n                  <Button variant=\"destructive\" size=\"icon\" className=\"h-9 w-9 shrink-0\" disabled={selectedCount === 0} onClick={handleDeleteSelected} title={t('record.mark.toolbar.delete')}>\n                    <Trash2 className=\"size-4\" />\n                  </Button>\n                </div>\n                <Button variant=\"default\" size=\"icon\" className=\"ml-auto h-9 w-9 shrink-0\" onClick={() => setMultiMode(false)} title={t('record.mark.toolbar.exitMultiSelect')}>\n                  <XSquare className=\"size-4\" />\n                </Button>\n              </>\n            )}\n          </div>\n        </div>\n      )}\n\n      <div className=\"flex-1 overflow-y-auto px-3 py-2\">\n        {!trashState && queues.length > 0 && (\n          <div className=\"mb-3 space-y-2\">\n            {queues.map((queue) => (\n              <div key={queue.queueId} className=\"rounded-xl border border-dashed bg-muted/40 px-3 py-2\">\n                <div className=\"flex items-center gap-2\">\n                  <Badge variant=\"secondary\" className=\"text-[10px]\">\n                    {t(`record.mark.type.${queue.type}`)}\n                  </Badge>\n                  <span className=\"text-xs text-muted-foreground\">{t('common.loading')}</span>\n                  <span className=\"ml-auto text-xs text-muted-foreground\">{queue.progress}</span>\n                </div>\n              </div>\n            ))}\n          </div>\n        )}\n\n        {groupedRecords.length === 0 ? (\n          <div className=\"py-14 text-center\">\n            <div className=\"text-sm text-muted-foreground\">{isFilterActive ? t('record.mark.list.emptyFiltered') : t('record.mark.empty')}</div>\n            {isFilterActive ? (\n              <Button variant=\"ghost\" size=\"sm\" className=\"mt-2\" onClick={handleResetFilters}>\n                {t('record.mark.toolbar.filter.clear')}\n              </Button>\n            ) : null}\n          </div>\n        ) : (\n          groupedRecords.map((group) => (\n            <div key={group.day} className=\"mb-4\">\n              <div className=\"mb-2 text-xs font-medium text-muted-foreground\">{getDayLabel(group.day)}</div>\n              <div className=\"space-y-2\">\n                {group.list.map((mark) => {\n                  const actionWidth = getActionWidth()\n                  const isCurrentSwiping = swipingMarkIdRef.current === mark.id\n                  const translateX = isCurrentSwiping\n                    ? swipeDeltaX\n                    : swipedMarkId === mark.id\n                      ? -actionWidth\n                      : 0\n\n                  return (\n                  <div key={mark.id} className=\"relative overflow-hidden rounded-xl bg-background\">\n                    {!multiMode && (\n                      <div className=\"absolute inset-y-0 right-0 flex items-center gap-2 px-2\">\n                        {trashState ? (\n                          <>\n                            <Button\n                              variant=\"outline\"\n                              size=\"icon\"\n                              className=\"size-11 rounded-xl shadow-sm\"\n                              onClick={() => {\n                                handleRestore(mark)\n                                setSwipedMarkId(null)\n                              }}\n                              title={t('record.mark.toolbar.restore')}\n                              aria-label={t('record.mark.toolbar.restore')}\n                            >\n                              <RotateCcw className=\"size-4\" />\n                              <span className=\"sr-only\">{t('record.mark.toolbar.restore')}</span>\n                            </Button>\n                            <Button\n                              variant=\"destructive\"\n                              size=\"icon\"\n                              className=\"size-11 rounded-xl shadow-sm\"\n                              onClick={() => {\n                                handleDelete(mark)\n                                setSwipedMarkId(null)\n                              }}\n                              title={t('record.mark.toolbar.deleteForever')}\n                              aria-label={t('record.mark.toolbar.deleteForever')}\n                            >\n                              <Trash2 className=\"size-4\" />\n                              <span className=\"sr-only\">{t('record.mark.toolbar.deleteForever')}</span>\n                            </Button>\n                          </>\n                        ) : (\n                          <>\n                            <Button\n                              variant=\"outline\"\n                              size=\"icon\"\n                              className=\"size-11 rounded-xl shadow-sm\"\n                              disabled={!canMoveBetweenTags}\n                              onClick={() => {\n                                setMoveTargetMark(mark)\n                                setSwipedMarkId(null)\n                              }}\n                              title={t('record.mark.toolbar.moveTag')}\n                              aria-label={t('record.mark.toolbar.moveTag')}\n                            >\n                              <MoveRight className=\"size-4\" />\n                              <span className=\"sr-only\">{t('record.mark.toolbar.moveTag')}</span>\n                            </Button>\n                            <Button\n                              variant=\"destructive\"\n                              size=\"icon\"\n                              className=\"size-11 rounded-xl shadow-sm\"\n                              onClick={() => {\n                                handleDelete(mark)\n                                setSwipedMarkId(null)\n                              }}\n                              title={t('record.mark.toolbar.delete')}\n                              aria-label={t('record.mark.toolbar.delete')}\n                            >\n                              <Trash2 className=\"size-4\" />\n                              <span className=\"sr-only\">{t('record.mark.toolbar.delete')}</span>\n                            </Button>\n                          </>\n                        )}\n                      </div>\n                    )}\n\n                    <div\n                      className=\"rounded-xl border bg-background px-3 py-3 transition-transform duration-200 ease-out\"\n                      style={{ transform: `translateX(${translateX}px)` }}\n                      onTouchStart={(e) => handleItemTouchStart(e, mark.id)}\n                      onTouchMove={handleItemTouchMove}\n                      onTouchEnd={handleItemTouchEnd}\n                    >\n                    <div className=\"flex items-start gap-2\">\n                      {multiMode ? (\n                        <div className=\"pt-1\">\n                          <Checkbox checked={selectedIds.has(mark.id)} onCheckedChange={() => toggleSelect(mark.id)} />\n                        </div>\n                      ) : null}\n\n                      <button\n                        type=\"button\"\n                        className=\"min-w-0 flex-1 text-left\"\n                        onClick={() => {\n                          if (swipedMarkId === mark.id) {\n                            setSwipedMarkId(null)\n                            return\n                          }\n                          setActiveMark(mark)\n                        }}\n                      >\n                        <div className=\"flex items-center gap-2\">\n                          <Badge variant=\"secondary\" className=\"text-[10px]\">\n                            {t(`record.mark.type.${mark.type}`)}\n                          </Badge>\n                          <span className=\"text-xs text-muted-foreground\">{dayjs(mark.createdAt).format('HH:mm')}</span>\n                          {!trashState && (\n                            <span className=\"ml-auto text-xs text-muted-foreground\">{tagMap.get(mark.tagId) || '-'}</span>\n                          )}\n                        </div>\n                        {(mark.type === 'image' || mark.type === 'scan') && mark.url ? (\n                          <div className=\"mt-2 flex items-center gap-2\">\n                            <LocalImage\n                              src={mark.url.includes('http') ? mark.url : `/${mark.type === 'scan' ? 'screenshot' : 'image'}/${mark.url}`}\n                              alt=\"\"\n                              className=\"h-12 w-12 rounded-md object-cover\"\n                            />\n                            <p className=\"line-clamp-2 text-sm text-muted-foreground\">{getMarkPreview(mark) || '-'}</p>\n                          </div>\n                        ) : (\n                          <p className=\"mt-2 line-clamp-2 text-sm\">{getMarkPreview(mark) || '-'}</p>\n                        )}\n                      </button>\n                    </div>\n                    </div>\n                  </div>\n                )})}\n              </div>\n            </div>\n          ))\n        )}\n      </div>\n\n      <Sheet open={Boolean(activeMark)} onOpenChange={(open) => !open && setActiveMark(null)}>\n        <SheetContent side=\"bottom\" className=\"max-h-[80vh] overflow-y-auto rounded-t-2xl\">\n          {activeMark && (\n            <>\n              <SheetHeader>\n                <SheetTitle>{t(`record.mark.type.${activeMark.type}`)}</SheetTitle>\n              </SheetHeader>\n              <div className=\"mt-3 space-y-3 text-sm\">\n                <div className=\"text-xs text-muted-foreground\">{dayjs(activeMark.createdAt).format('YYYY-MM-DD HH:mm:ss')}</div>\n                {(activeMark.type === 'image' || activeMark.type === 'scan') && activeMark.url && (\n                  <div className=\"overflow-hidden rounded-lg border bg-muted/20 p-2\">\n                    <LocalImage\n                      src={activeMark.url.includes('http') ? activeMark.url : `/${activeMark.type === 'scan' ? 'screenshot' : 'image'}/${activeMark.url}`}\n                      alt=\"\"\n                      className=\"h-48 w-full rounded-md object-contain\"\n                    />\n                  </div>\n                )}\n                {activeMark.type !== 'text' && (\n                  <div>\n                    <div className=\"mb-1 text-xs text-muted-foreground\">{t('record.mark.desc')}</div>\n                    <Textarea\n                      value={editDesc}\n                      onChange={(e) => setEditDesc(e.target.value)}\n                      rows={3}\n                      className=\"min-h-20\"\n                    />\n                  </div>\n                )}\n                <div>\n                  <div className=\"mb-1 text-xs text-muted-foreground\">{t('record.mark.content')}</div>\n                  <Textarea\n                    value={editContent}\n                    onChange={(e) => {\n                      const next = e.target.value\n                      setEditContent(next)\n                      if (activeMark.type === 'text') {\n                        setEditDesc(next)\n                      }\n                    }}\n                    rows={8}\n                    className=\"min-h-28\"\n                  />\n                </div>\n                {activeMark.type === 'link' && (\n                  <div>\n                    <div className=\"mb-1 text-xs text-muted-foreground\">URL</div>\n                    <Input value={editUrl} onChange={(e) => setEditUrl(e.target.value)} />\n                  </div>\n                )}\n              </div>\n            </>\n          )}\n        </SheetContent>\n      </Sheet>\n\n      <Sheet open={createTagOpen} onOpenChange={setCreateTagOpen}>\n        <SheetContent side=\"bottom\" className=\"rounded-t-2xl\">\n          <SheetHeader>\n            <SheetTitle>{t('record.mark.tag.newTag')}</SheetTitle>\n          </SheetHeader>\n          <div className=\"mt-4 space-y-3\">\n            <Input\n              value={newTagName}\n              onChange={(e) => setNewTagName(e.target.value)}\n              placeholder={t('record.mark.tag.newTagPlaceholder')}\n              className=\"h-10\"\n            />\n            <Button onClick={handleCreateTag} className=\"h-10 w-full\">\n              {t('record.mark.tag.add')}\n            </Button>\n          </div>\n        </SheetContent>\n      </Sheet>\n\n      <Sheet open={typeFilterOpen} onOpenChange={setTypeFilterOpen}>\n        <SheetContent side=\"bottom\" className=\"rounded-t-2xl\">\n          <SheetHeader>\n            <SheetTitle>{t('record.mark.toolbar.filter.title')}</SheetTitle>\n          </SheetHeader>\n          <div className=\"mt-4 space-y-3\">\n            <Card className=\"border-border/60 shadow-sm\">\n              <CardHeader className=\"pb-3\">\n                <CardTitle className=\"text-sm\">{t('record.mark.toolbar.filter.title')}</CardTitle>\n                <CardDescription>{t('record.mark.toolbar.filter.description')}</CardDescription>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                <div className=\"space-y-2\">\n                  <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">{t('record.mark.toolbar.filter.search')}</div>\n                  <div className=\"relative\">\n                    <Search className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n                    <Input\n                      value={recordFilters.search}\n                      onChange={(event) => setRecordSearch(event.target.value)}\n                      placeholder={t('record.mark.toolbar.filter.searchPlaceholder')}\n                      className=\"h-10 pl-9\"\n                    />\n                  </div>\n                </div>\n\n                <div className=\"space-y-2\">\n                  <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">{t('record.mark.toolbar.filter.time')}</div>\n                  <div className=\"grid grid-cols-2 gap-1 rounded-xl border bg-muted/35 p-1\">\n                    {TIME_OPTIONS.map((preset) => (\n                      <Button\n                        key={preset}\n                        type=\"button\"\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        onClick={() => setRecordTimePreset(preset)}\n                        className={cn(\n                          'h-9 justify-center rounded-lg px-2 text-xs font-medium',\n                          recordFilters.timePreset === preset\n                            ? 'bg-background shadow-sm text-foreground hover:bg-background'\n                            : 'text-muted-foreground hover:bg-background/70 hover:text-foreground'\n                        )}\n                      >\n                        {t(`record.mark.toolbar.filter.timeOptions.${preset}`)}\n                      </Button>\n                    ))}\n                  </div>\n                </div>\n\n                <div className=\"space-y-2\">\n                  <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">{t('record.mark.toolbar.filter.type')}</div>\n                  <div className=\"grid grid-cols-2 gap-2\">\n                    {MARK_TYPE_OPTIONS.map((type) => (\n                      <Button\n                        key={type}\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={() => toggleTypeFilter(type)}\n                        className={cn(\n                          'h-9 justify-start rounded-lg px-3 text-sm',\n                          getMarkTypeChipClasses(type, recordFilters.selectedTypes.includes(type))\n                        )}\n                      >\n                        {t(`record.mark.type.${type}`)}\n                      </Button>\n                    ))}\n                  </div>\n                  <Button variant=\"ghost\" size=\"sm\" className=\"h-8 px-0 text-xs text-muted-foreground\" onClick={selectAllTypes}>\n                    {selectedTypeCount === MARK_TYPE_OPTIONS.length && selectedTypeCount > 0 ? t('record.mark.toolbar.filter.clearTypes') : t('record.mark.toolbar.filter.selectAllTypes')}\n                  </Button>\n                </div>\n\n                <div className=\"space-y-2\">\n                  <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">{t('record.mark.toolbar.filter.tag')}</div>\n                  <Select value={String(recordFilters.tagId)} onValueChange={(value) => setRecordTagId(value === 'all' ? 'all' : Number(value))}>\n                    <SelectTrigger className=\"h-10 rounded-lg\">\n                      <SelectValue placeholder={t('record.mark.toolbar.filter.allTags')} />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"all\">{t('record.mark.toolbar.filter.allTags')}</SelectItem>\n                      {tags.map((tag) => (\n                        <SelectItem key={tag.id} value={String(tag.id)}>\n                          {tag.name}\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                </div>\n              </CardContent>\n            </Card>\n\n            <div className=\"flex justify-end\">\n              <Button variant=\"outline\" className=\"h-9 gap-2\" onClick={handleResetFilters} disabled={!isFilterActive}>\n                <RotateCcw className=\"h-3.5 w-3.5\" />\n                {t('record.mark.toolbar.filter.clear')}\n              </Button>\n            </div>\n          </div>\n        </SheetContent>\n      </Sheet>\n\n      <Sheet open={Boolean(moveTargetMark)} onOpenChange={(open) => !open && setMoveTargetMark(null)}>\n        <SheetContent side=\"bottom\" className=\"rounded-t-2xl\">\n          <SheetHeader>\n            <SheetTitle>{t('record.mark.toolbar.moveTag')}</SheetTitle>\n          </SheetHeader>\n          <div className=\"mt-4 space-y-2\">\n            {tags.filter((tag) => tag.id !== moveTargetMark?.tagId).map((tag) => (\n              <Button key={tag.id} variant=\"outline\" className=\"h-10 w-full justify-start\" onClick={() => handleMoveTargetTag(tag.id)}>\n                {tag.name}\n              </Button>\n            ))}\n          </div>\n        </SheetContent>\n      </Sheet>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/record/page.tsx",
    "content": "'use client'\nimport { MobileMarkHeader } from './mobile-mark-header'\nimport { MobileRecordStream } from './mobile-record-stream'\n\nexport default function Record() {\n  return (\n    <div id=\"mobile-record\" className=\"flex flex-col h-full w-full bg-background\">\n      <MobileMarkHeader />\n      <MobileRecordStream />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/components/setting-tab.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\nimport baseConfig from '@/app/core/setting/config'\nimport { useTranslations } from 'next-intl'\nimport { ChevronRight } from \"lucide-react\";\n\nexport function SettingTab() {\n  const router = useRouter()\n  const t = useTranslations('settings')\n  const notMobilePages = ['about', 'file', 'shortcuts']\n  \n  // Add translations to the config, keep separators\n  const config = baseConfig.map(item => {\n    if (typeof item === 'string') return item\n    return {\n      ...item,\n      title: t(`${item.anchor}.title`)\n    }\n  }).filter(item => {\n    // 过滤掉不支持的移动端页面，但保留分隔符\n    if (typeof item === 'string') return true\n    return !notMobilePages.includes(item.anchor)\n  })\n\n  function handleNavigation(anchor: string) {\n    router.push(`/mobile/setting/pages/${anchor}`)\n  }\n\n  return (\n    <ul className=\"flex flex-col w-full\">\n      {\n        config.map((item, index) => {\n          // 如果是分隔符字符串，渲染分隔线\n          if (typeof item === 'string') {\n            return (\n              <li key={`separator-${index}`}>\n                <div className=\"h-0.5 bg-muted my-2\" />\n              </li>\n            )\n          }\n          \n          return (\n            <li\n              className=\"flex items-center gap-2 p-4 w-full justify-between active:bg-accent\"\n              key={item.anchor}\n              onClick={() => handleNavigation(item.anchor)}\n            >\n              <div className=\"flex items-center gap-4\">\n                {item.icon}\n                <span className=\"text-sm\">{item.title}</span>\n              </div>\n              <ChevronRight className=\"size-4\" />\n            </li>\n          )\n        })\n      }\n    </ul>\n  )\n}"
  },
  {
    "path": "src/app/mobile/setting/page.tsx",
    "content": "'use client'\nimport { SettingTab } from \"./components/setting-tab\"\nimport Updater from \"@/app/core/setting/about/updater\"\nimport { SyncToggle } from \"@/components/title-bar-toolbars/sync-toggle\"\n\nexport default function Setting() {\n  return <div id=\"mobile-setting\" className=\"flex w-full h-full overflow-y-auto flex-col\">\n    <div className=\"mobile-page-header flex items-center justify-between p-2 border-b overflow-hidden\">\n      <div className=\"flex items-center gap-2\">\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <SyncToggle />\n      </div>\n    </div>\n    <div className=\"flex-1 overflow-y-auto\">\n      <div className=\"p-2 my-4\">\n        <Updater />\n      </div>\n      <SettingTab />\n    </div>\n  </div>\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/ai/page.tsx",
    "content": "'use client';\n\nimport SettingAI from \"@/app/core/setting/ai/page\";\n\nexport default function AIPage() {\n  return <SettingAI />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/audio/page.tsx",
    "content": "'use client';\n\nimport SettingAudio from \"@/app/core/setting/audio/page\";\n\nexport default function AudioPage() {\n  return <SettingAudio />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/chat/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { MessageSquare } from 'lucide-react'\nimport { DefaultModelsSettings } from '@/app/core/setting/components/default-models-settings'\nimport { ToolbarSettings } from '@/app/core/setting/chat/toolbar-settings'\nimport { CondenseSettings } from '@/app/core/setting/chat/condense-settings'\n\nexport default function ChatSettingsPage() {\n  const t = useTranslations('settings.chat')\n\n  return (\n    <div className='space-y-6'>\n      <div>\n        <h1 className=\"text-2xl font-bold mb-2 flex items-center gap-2\">\n          <MessageSquare className=\"size-6\" />\n          {t('title')}\n        </h1>\n        <p className=\"text-sm text-muted-foreground\">{t('desc')}</p>\n      </div>\n      <DefaultModelsSettings type=\"chat\" />\n      <ToolbarSettings />\n      <CondenseSettings />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/defaultModel/page.tsx",
    "content": "'use client';\n\nimport SettingDefaultModel from \"@/app/core/setting/defaultModel/page\";\n\nexport default function DefaultModelPage() {\n  return <SettingDefaultModel />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/dev/page.tsx",
    "content": "'use client';\n\nimport SettingDev from \"@/app/core/setting/dev/page\";\n\nexport default function DevPage() {\n  return <SettingDev />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/editor/page.tsx",
    "content": "'use client';\n\nimport SettingEditor from \"@/app/core/setting/editor/page\";\n\nexport default function EditorPage() {\n  return <SettingEditor />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/file/page.tsx",
    "content": "'use client';\n\nimport { SettingWorkspace } from \"@/app/core/setting/file/setting-workspace\";\n\nexport default function FilePage() {\n  return <SettingWorkspace />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/general/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { InterfaceSettings } from '@/app/core/setting/general/interface-settings'\nimport { ToolSettings } from '@/app/core/setting/general/tool-settings'\n\nexport default function GeneralSettingsPage() {\n  const t = useTranslations('settings.general')\n\n  return (\n    <div className='space-y-6'>\n      <div>\n        <h1 className=\"text-2xl font-bold mb-2\">{t('title')}</h1>\n        <p className=\"text-sm text-muted-foreground\">{t('desc')}</p>\n      </div>\n      <InterfaceSettings />\n      <ToolSettings />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/imageHosting/page.tsx",
    "content": "'use client';\n\nimport SettingImageHosting from \"@/app/core/setting/imageHosting/page\";\n\nexport default function ImageHostingPage() {\n  return <SettingImageHosting />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/imageMethod/page.tsx",
    "content": "'use client';\n\nimport SettingImageMethod from \"@/app/core/setting/imageMethod/page\";\n\nexport default function ImageMethodPage() {\n  return <SettingImageMethod />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/layout.tsx",
    "content": "'use client'\n\nimport { Button } from \"@/components/ui/button\";\nimport { useRouter } from \"next/navigation\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { SwipeBack } from \"@/components/ui/swipe-back\";\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  const router = useRouter()\n  return (\n    <SwipeBack>\n      <div className=\"flex flex-col w-full h-screen overflow-y-auto pt-24 pb-40\">\n        <div className=\"fixed top-0 left-0 right-0 z-10 flex items-center p-2 border-b bg-background\">\n          <Button variant=\"ghost\" size=\"icon\" onClick={() => router.back()}>\n            <ArrowLeft />\n          </Button>\n        </div>\n        <div className=\"flex-1 w-full p-2\">\n          {children}\n        </div>\n      </div>\n    </SwipeBack>\n  )\n}"
  },
  {
    "path": "src/app/mobile/setting/pages/mcp/page.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { ServerList } from '@/app/core/setting/mcp/server-list'\nimport { useMcpStore } from '@/stores/mcp'\n\nexport default function McpSettingPage() {\n  const t = useTranslations('settings.mcp')\n  const { initMcpData } = useMcpStore()\n\n  useEffect(() => {\n    initMcpData()\n  }, [])\n\n  return (\n    <div>\n      <div className=\"mb-6\">\n        <h1 className=\"text-2xl font-bold mb-2\">{t('title')}</h1>\n        <p className=\"text-sm text-muted-foreground\">{t('desc')}</p>\n      </div>\n      <div className=\"mb-4 rounded-lg border border-dashed p-4\">\n        <p className=\"text-sm font-medium\">{t('mobileHttpOnlyTitle')}</p>\n        <p className=\"text-sm text-muted-foreground\">{t('mobileHttpOnlyDesc')}</p>\n      </div>\n      <div className=\"space-y-6\">\n        <ServerList />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/prompt/page.tsx",
    "content": "'use client';\n\nimport SettingPrompt from \"@/app/core/setting/prompt/page\";\n\nexport default function PromptPage() {\n  return <SettingPrompt />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/rag/page.tsx",
    "content": "'use client';\n\nimport SettingRAG from \"@/app/core/setting/rag/page\";\n\nexport default function RAGPage() {\n  return <SettingRAG />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/readAloud/page.tsx",
    "content": "'use client';\n\nimport SettingReadAloud from \"@/app/core/setting/readAloud/page\";\n\nexport default function ReadAloudPage() {\n  return <SettingReadAloud />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/record/page.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { PenTool } from 'lucide-react'\nimport { DefaultModelsSettings } from '@/app/core/setting/components/default-models-settings'\nimport { ToolbarSettings } from '@/app/core/setting/record/toolbar-settings'\n\nexport default function RecordSettingsPage() {\n  const t = useTranslations('settings.record')\n\n  return (\n    <div className='space-y-6'>\n      <div>\n        <h1 className=\"text-2xl font-bold mb-2 flex items-center gap-2\">\n          <PenTool className=\"size-6\" />\n          {t('title')}\n        </h1>\n        <p className=\"text-sm text-muted-foreground\">{t('desc')}</p>\n      </div>\n      <DefaultModelsSettings type=\"record\" />\n      <ToolbarSettings />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/skills/page.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { useSkillsStore } from '@/stores/skills'\nimport { GlobalSkillsManager } from '@/app/core/setting/skills/components/global-skills-manager'\n\nexport default function SkillsPage() {\n  const t = useTranslations('settings.skills')\n  const { initSkills } = useSkillsStore()\n\n  useEffect(() => {\n    initSkills()\n  }, [initSkills])\n\n  return (\n    <div>\n      <div className=\"mb-6\">\n        <h1 className=\"text-2xl font-bold mb-2\">{t('title')}</h1>\n        <p className=\"text-sm text-muted-foreground\">{t('desc')}</p>\n      </div>\n      <div className=\"space-y-6\">\n        <GlobalSkillsManager />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/sync/page.tsx",
    "content": "'use client';\n\nimport SettingSync from \"@/app/core/setting/sync/page\";\n\nexport default function SyncPage() {\n  return <SettingSync />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/template/page.tsx",
    "content": "'use client';\n\nimport SettingTemplate from \"@/app/core/setting/template/page\";\n\nexport default function TemplatePage() {\n  return <SettingTemplate />\n}\n"
  },
  {
    "path": "src/app/mobile/setting/pages/theme/page.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { ArrowLeft, Download, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport useSettingStore from '@/stores/setting'\nimport { HSLValue } from '@/types/theme'\nimport { applyThemeColors, hslToHex } from '@/lib/theme-utils'\nimport { useRouter } from 'next/navigation'\nimport { Store } from '@tauri-apps/plugin-store'\n\ninterface ColorScheme {\n  name: string\n  mode: 'light' | 'dark'\n  colors: {\n    background: string\n    foreground: string\n    card: string\n    cardForeground: string\n    primary: string\n    primaryForeground: string\n    secondary: string\n    secondaryForeground: string\n    third: string\n    thirdForeground: string\n    muted: string\n    mutedForeground: string\n    accent: string\n    accentForeground: string\n    border: string\n    shadow: string\n  }\n}\n\nconst presets: ColorScheme[] = [\n  {\n    name: '默认白色',\n    mode: 'light',\n    colors: {\n      background: '#ffffff',\n      foreground: '#0a0a0a',\n      card: '#ffffff',\n      cardForeground: '#0a0a0a',\n      primary: '#171717',\n      primaryForeground: '#fafafa',\n      secondary: '#f5f5f5',\n      secondaryForeground: '#171717',\n      third: '#e5e5e5',\n      thirdForeground: '#262626',\n      muted: '#f5f5f5',\n      mutedForeground: '#737373',\n      accent: '#f5f5f5',\n      accentForeground: '#171717',\n      border: '#e5e5e5',\n      shadow: '#000000',\n    },\n  },\n  {\n    name: '海洋蓝',\n    mode: 'light',\n    colors: {\n      background: '#f0f9ff',\n      foreground: '#0c4a6e',\n      card: '#ffffff',\n      cardForeground: '#0c4a6e',\n      primary: '#0284c7',\n      primaryForeground: '#ffffff',\n      secondary: '#e0f2fe',\n      secondaryForeground: '#0c4a6e',\n      third: '#bae6fd',\n      thirdForeground: '#0369a1',\n      muted: '#f1f5f9',\n      mutedForeground: '#64748b',\n      accent: '#0ea5e9',\n      accentForeground: '#ffffff',\n      border: '#bae6fd',\n      shadow: '#0c4a6e',\n    },\n  },\n  {\n    name: '森林绿',\n    mode: 'light',\n    colors: {\n      background: '#f0fdf4',\n      foreground: '#14532d',\n      card: '#ffffff',\n      cardForeground: '#14532d',\n      primary: '#16a34a',\n      primaryForeground: '#ffffff',\n      secondary: '#dcfce7',\n      secondaryForeground: '#14532d',\n      third: '#bbf7d0',\n      thirdForeground: '#166534',\n      muted: '#f7fee7',\n      mutedForeground: '#4d7c0f',\n      accent: '#22c55e',\n      accentForeground: '#ffffff',\n      border: '#bbf7d0',\n      shadow: '#14532d',\n    },\n  },\n  {\n    name: '日落红',\n    mode: 'light',\n    colors: {\n      background: '#fef2f2',\n      foreground: '#7f1d1d',\n      card: '#ffffff',\n      cardForeground: '#7f1d1d',\n      primary: '#dc2626',\n      primaryForeground: '#ffffff',\n      secondary: '#fee2e2',\n      secondaryForeground: '#7f1d1d',\n      third: '#fecaca',\n      thirdForeground: '#b91c1c',\n      muted: '#fef2f2',\n      mutedForeground: '#991b1b',\n      accent: '#f87171',\n      accentForeground: '#ffffff',\n      border: '#fecaca',\n      shadow: '#7f1d1d',\n    },\n  },\n  {\n    name: '薰衣草紫',\n    mode: 'light',\n    colors: {\n      background: '#faf5ff',\n      foreground: '#581c87',\n      card: '#ffffff',\n      cardForeground: '#581c87',\n      primary: '#9333ea',\n      primaryForeground: '#ffffff',\n      secondary: '#f3e8ff',\n      secondaryForeground: '#581c87',\n      third: '#e9d5ff',\n      thirdForeground: '#7e22ce',\n      muted: '#faf5ff',\n      mutedForeground: '#7e22ce',\n      accent: '#a855f7',\n      accentForeground: '#ffffff',\n      border: '#e9d5ff',\n      shadow: '#581c87',\n    },\n  },\n  {\n    name: '午夜暗',\n    mode: 'dark',\n    colors: {\n      background: '#1a1a2e',\n      foreground: '#eaeaea',\n      card: '#16213e',\n      cardForeground: '#eaeaea',\n      primary: '#0f3460',\n      primaryForeground: '#eaeaea',\n      secondary: '#1f4068',\n      secondaryForeground: '#eaeaea',\n      third: '#0f3460',\n      thirdForeground: '#a0a0a0',\n      muted: '#16213e',\n      mutedForeground: '#a0a0a0',\n      accent: '#e94560',\n      accentForeground: '#ffffff',\n      border: '#0f3460',\n      shadow: '#000000',\n    },\n  },\n  {\n    name: '深海',\n    mode: 'dark',\n    colors: {\n      background: '#0f172a',\n      foreground: '#e2e8f0',\n      card: '#1e293b',\n      cardForeground: '#e2e8f0',\n      primary: '#3b82f6',\n      primaryForeground: '#ffffff',\n      secondary: '#334155',\n      secondaryForeground: '#e2e8f0',\n      third: '#1e3a8a',\n      thirdForeground: '#cbd5e1',\n      muted: '#1e293b',\n      mutedForeground: '#94a3b8',\n      accent: '#60a5fa',\n      accentForeground: '#ffffff',\n      border: '#334155',\n      shadow: '#020617',\n    },\n  },\n  {\n    name: '暗夜绿',\n    mode: 'dark',\n    colors: {\n      background: '#0a1f1a',\n      foreground: '#e2e8f0',\n      card: '#142b26',\n      cardForeground: '#e2e8f0',\n      primary: '#22c55e',\n      primaryForeground: '#ffffff',\n      secondary: '#1a3a33',\n      secondaryForeground: '#e2e8f0',\n      third: '#14532d',\n      thirdForeground: '#bbf7d0',\n      muted: '#142b26',\n      mutedForeground: '#86efac',\n      accent: '#4ade80',\n      accentForeground: '#0a1f1a',\n      border: '#1a3a33',\n      shadow: '#052e16',\n    },\n  },\n  {\n    name: '紫罗兰暗',\n    mode: 'dark',\n    colors: {\n      background: '#1a0b2e',\n      foreground: '#e2e8f0',\n      card: '#2d1b4e',\n      cardForeground: '#e2e8f0',\n      primary: '#a855f7',\n      primaryForeground: '#ffffff',\n      secondary: '#3b2466',\n      secondaryForeground: '#e2e8f0',\n      third: '#581c87',\n      thirdForeground: '#d8b4fe',\n      muted: '#2d1b4e',\n      mutedForeground: '#c4b5fd',\n      accent: '#c084fc',\n      accentForeground: '#1a0b2e',\n      border: '#3b2466',\n      shadow: '#2e1065',\n    },\n  },\n  {\n    name: '珊瑚暖',\n    mode: 'light',\n    colors: {\n      background: '#fff7ed',\n      foreground: '#431407',\n      card: '#ffffff',\n      cardForeground: '#431407',\n      primary: '#ea580c',\n      primaryForeground: '#ffffff',\n      secondary: '#ffedd5',\n      secondaryForeground: '#431407',\n      third: '#fed7aa',\n      thirdForeground: '#c2410c',\n      muted: '#fed7aa',\n      mutedForeground: '#9a3412',\n      accent: '#fb923c',\n      accentForeground: '#ffffff',\n      border: '#fed7aa',\n      shadow: '#431407',\n    },\n  },\n  {\n    name: '石板灰',\n    mode: 'light',\n    colors: {\n      background: '#f8fafc',\n      foreground: '#1e293b',\n      card: '#ffffff',\n      cardForeground: '#1e293b',\n      primary: '#475569',\n      primaryForeground: '#ffffff',\n      secondary: '#e2e8f0',\n      secondaryForeground: '#1e293b',\n      third: '#cbd5e1',\n      thirdForeground: '#334155',\n      muted: '#f1f5f9',\n      mutedForeground: '#64748b',\n      accent: '#64748b',\n      accentForeground: '#ffffff',\n      border: '#e2e8f0',\n      shadow: '#0f172a',\n    },\n  },\n  {\n    name: '暗夜金',\n    mode: 'dark',\n    colors: {\n      background: '#1a1915',\n      foreground: '#e2e8f0',\n      card: '#2a2924',\n      cardForeground: '#e2e8f0',\n      primary: '#fbbf24',\n      primaryForeground: '#1a1915',\n      secondary: '#3a3934',\n      secondaryForeground: '#e2e8f0',\n      third: '#78350f',\n      thirdForeground: '#fde68a',\n      muted: '#2a2924',\n      mutedForeground: '#fcd34d',\n      accent: '#f59e0b',\n      accentForeground: '#1a1915',\n      border: '#3a3934',\n      shadow: '#000000',\n    },\n  },\n]\n\nexport default function ThemeSettingsPage() {\n  const { customThemeColors } = useSettingStore()\n  const router = useRouter()\n  const [activeTab, setActiveTab] = useState<'custom' | 'presets' | 'import-export'>('custom')\n  const [importCode, setImportCode] = useState('')\n  const [exportCode, setExportCode] = useState('')\n\n  // 实时保存颜色变化\n  const handleColorChange = async (colorKey: string, value: HSLValue | null) => {\n    const updatedColors = {\n      light: {\n        ...customThemeColors.light,\n        [colorKey]: value,\n      },\n      dark: {\n        ...customThemeColors.dark,\n        [colorKey]: value,\n      },\n    }\n\n    const store = await Store.load('store.json')\n    await store.set('customThemeColors', updatedColors)\n    await store.save()\n    useSettingStore.setState({ customThemeColors: updatedColors })\n    applyThemeColors(updatedColors)\n  }\n\n  // 应用预设方案\n  const applyPreset = async (preset: ColorScheme) => {\n    const hexToHsl = (hex: string): HSLValue | null => {\n      const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n      if (!result) return null\n      const r = parseInt(result[1], 16)\n      const g = parseInt(result[2], 16)\n      const b = parseInt(result[3], 16)\n      const rNorm = r / 255\n      const gNorm = g / 255\n      const bNorm = b / 255\n      const max = Math.max(rNorm, gNorm, bNorm)\n      const min = Math.min(rNorm, gNorm, bNorm)\n      let h = 0, s = 0\n      const l = (max + min) / 2\n      if (max !== min) {\n        const d = max - min\n        s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n        switch (max) {\n          case rNorm: h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; break\n          case gNorm: h = ((bNorm - rNorm) / d + 2) / 6; break\n          case bNorm: h = ((rNorm - gNorm) / d + 4) / 6; break\n        }\n      }\n      return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]\n    }\n\n    const updatedColors = {\n      light: {\n        background: hexToHsl(preset.colors.background),\n        foreground: hexToHsl(preset.colors.foreground),\n        card: hexToHsl(preset.colors.card),\n        cardForeground: hexToHsl(preset.colors.cardForeground),\n        primary: hexToHsl(preset.colors.primary),\n        primaryForeground: hexToHsl(preset.colors.primaryForeground),\n        secondary: hexToHsl(preset.colors.secondary),\n        secondaryForeground: hexToHsl(preset.colors.secondaryForeground),\n        third: hexToHsl(preset.colors.third),\n        thirdForeground: hexToHsl(preset.colors.thirdForeground),\n        muted: hexToHsl(preset.colors.muted),\n        mutedForeground: hexToHsl(preset.colors.mutedForeground),\n        accent: hexToHsl(preset.colors.accent),\n        accentForeground: hexToHsl(preset.colors.accentForeground),\n        border: hexToHsl(preset.colors.border),\n        shadow: hexToHsl(preset.colors.shadow),\n      },\n      dark: {\n        background: hexToHsl(preset.colors.background),\n        foreground: hexToHsl(preset.colors.foreground),\n        card: hexToHsl(preset.colors.card),\n        cardForeground: hexToHsl(preset.colors.cardForeground),\n        primary: hexToHsl(preset.colors.primary),\n        primaryForeground: hexToHsl(preset.colors.primaryForeground),\n        secondary: hexToHsl(preset.colors.secondary),\n        secondaryForeground: hexToHsl(preset.colors.secondaryForeground),\n        third: hexToHsl(preset.colors.third),\n        thirdForeground: hexToHsl(preset.colors.thirdForeground),\n        muted: hexToHsl(preset.colors.muted),\n        mutedForeground: hexToHsl(preset.colors.mutedForeground),\n        accent: hexToHsl(preset.colors.accent),\n        accentForeground: hexToHsl(preset.colors.accentForeground),\n        border: hexToHsl(preset.colors.border),\n        shadow: hexToHsl(preset.colors.shadow),\n      },\n    }\n\n    const store = await Store.load('store.json')\n    await store.set('customThemeColors', updatedColors)\n    await store.save()\n    useSettingStore.setState({ customThemeColors: updatedColors })\n    applyThemeColors(updatedColors)\n  }\n\n  // 生成导出代码\n  const handleExport = () => {\n    const exportData = {\n      name: 'Custom Theme',\n      colors: {\n        background: hslToHex(customThemeColors.light.background || [0, 0, 100]),\n        foreground: hslToHex(customThemeColors.light.foreground || [0, 0, 0]),\n        card: hslToHex(customThemeColors.light.card || [0, 0, 100]),\n        cardForeground: hslToHex(customThemeColors.light.cardForeground || [0, 0, 0]),\n        primary: hslToHex(customThemeColors.light.primary || [0, 0, 0]),\n        primaryForeground: hslToHex(customThemeColors.light.primaryForeground || [0, 0, 100]),\n        secondary: hslToHex(customThemeColors.light.secondary || [0, 0, 100]),\n        secondaryForeground: hslToHex(customThemeColors.light.secondaryForeground || [0, 0, 0]),\n        muted: hslToHex(customThemeColors.light.muted || [0, 0, 100]),\n        mutedForeground: hslToHex(customThemeColors.light.mutedForeground || [0, 0, 50]),\n        accent: hslToHex(customThemeColors.light.accent || [0, 0, 100]),\n        accentForeground: hslToHex(customThemeColors.light.accentForeground || [0, 0, 0]),\n        border: hslToHex(customThemeColors.light.border || [0, 0, 90]),\n      },\n    }\n    setExportCode(JSON.stringify(exportData, null, 2))\n  }\n\n  // 导入配色方案\n  const handleImport = async () => {\n    try {\n      const importData = JSON.parse(importCode) as ColorScheme\n      if (importData.colors) {\n        await applyPreset(importData)\n        setImportCode('')\n        setActiveTab('custom')\n      }\n    } catch (error) {\n      console.error('Import failed:', error)\n    }\n  }\n\n  const colorConfig: Array<{ key: string; label: string; defaultColor: string }> = [\n    { key: 'background', label: '背景色', defaultColor: '#ffffff' },\n    { key: 'foreground', label: '前景色', defaultColor: '#0a0a0a' },\n    { key: 'card', label: '卡片背景', defaultColor: '#ffffff' },\n    { key: 'cardForeground', label: '卡片前景', defaultColor: '#0a0a0a' },\n    { key: 'primary', label: '主色调', defaultColor: '#171717' },\n    { key: 'primaryForeground', label: '主色前景', defaultColor: '#fafafa' },\n    { key: 'secondary', label: '次要色', defaultColor: '#f5f5f5' },\n    { key: 'secondaryForeground', label: '次要前景', defaultColor: '#171717' },\n    { key: 'muted', label: '柔和色', defaultColor: '#f5f5f5' },\n    { key: 'mutedForeground', label: '柔和前景', defaultColor: '#737373' },\n    { key: 'accent', label: '强调色', defaultColor: '#f5f5f5' },\n    { key: 'accentForeground', label: '强调前景', defaultColor: '#171717' },\n    { key: 'border', label: '边框色', defaultColor: '#e5e5e5' },\n  ]\n\n  const hexToHsl = (hex: string): HSLValue | null => {\n    const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n    if (!result) return null\n    const r = parseInt(result[1], 16)\n    const g = parseInt(result[2], 16)\n    const b = parseInt(result[3], 16)\n    const rNorm = r / 255\n    const gNorm = g / 255\n    const bNorm = b / 255\n    const max = Math.max(rNorm, gNorm, bNorm)\n    const min = Math.min(rNorm, gNorm, bNorm)\n    let h = 0, s = 0\n    const l = (max + min) / 2\n    if (max !== min) {\n      const d = max - min\n      s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n      switch (max) {\n        case rNorm: h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; break\n        case gNorm: h = ((bNorm - rNorm) / d + 2) / 6; break\n        case bNorm: h = ((rNorm - gNorm) / d + 4) / 6; break\n      }\n    }\n    return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]\n  }\n\n  return (\n    <div className=\"min-h-screen bg-background\">\n      {/* 头部 */}\n      <div className=\"sticky top-0 z-10 bg-background border-b border-border\">\n        <div className=\"flex items-center px-4 py-3\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => router.back()}\n            className=\"shrink-0\"\n          >\n            <ArrowLeft className=\"h-5 w-5\" />\n          </Button>\n          <h1 className=\"text-lg font-semibold ml-2\">自定义主题色</h1>\n        </div>\n      </div>\n\n      {/* 内容 */}\n      <div className=\"p-4\">\n        <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'custom' | 'presets' | 'import-export')} className=\"w-full\">\n          <TabsList className=\"grid w-full grid-cols-3 mb-4\">\n            <TabsTrigger value=\"custom\">自定义</TabsTrigger>\n            <TabsTrigger value=\"presets\">预设</TabsTrigger>\n            <TabsTrigger value=\"import-export\">导入/导出</TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"custom\" className=\"space-y-3\">\n            {colorConfig.map((config) => {\n              const value = customThemeColors.light[config.key as keyof typeof customThemeColors.light]\n              const hexValue = value ? hslToHex(value) : config.defaultColor\n\n              return (\n                <div key={config.key} className=\"flex items-center gap-3 py-2\">\n                  <input\n                    type=\"color\"\n                    value={hexValue}\n                    onChange={(e) => {\n                      const hsl = hexToHsl(e.target.value)\n                      if (hsl) handleColorChange(config.key, hsl)\n                    }}\n                    className=\"w-12 h-12 rounded-lg cursor-pointer border-2 border-border\"\n                  />\n                  <div className=\"flex-1\">\n                    <Label className=\"text-sm font-medium\">{config.label}</Label>\n                    <p className=\"text-xs text-muted-foreground\">{hexValue}</p>\n                  </div>\n                </div>\n              )\n            })}\n          </TabsContent>\n\n          <TabsContent value=\"presets\" className=\"space-y-3\">\n            <div className=\"grid grid-cols-2 gap-3\">\n              {presets.map((preset) => (\n                <button\n                  key={preset.name}\n                  onClick={() => applyPreset(preset)}\n                  className=\"flex flex-col gap-2 p-3 rounded-lg border-2 border-border hover:border-primary transition-all\"\n                >\n                  <div className=\"flex h-2 rounded-full overflow-hidden\">\n                    <div className=\"flex-1\" style={{ backgroundColor: preset.colors.background }} />\n                    <div className=\"flex-1\" style={{ backgroundColor: preset.colors.foreground }} />\n                    <div className=\"flex-1\" style={{ backgroundColor: preset.colors.primary }} />\n                    <div className=\"flex-1\" style={{ backgroundColor: preset.colors.secondary }} />\n                    <div className=\"flex-1\" style={{ backgroundColor: preset.colors.accent }} />\n                  </div>\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\">\n                      {preset.mode === 'light' ? 'Light' : 'Dark'}\n                    </span>\n                    <span className=\"text-xs font-medium\">{preset.name}</span>\n                  </div>\n                </button>\n              ))}\n            </div>\n          </TabsContent>\n\n          <TabsContent value=\"import-export\" className=\"space-y-4\">\n            <div>\n              <div className=\"flex items-center justify-between mb-2\">\n                <h3 className=\"text-sm font-semibold\">导出配色</h3>\n                <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n                  <Download className=\"h-4 w-4 mr-1\" />\n                  生成\n                </Button>\n              </div>\n              <Textarea\n                value={exportCode}\n                onChange={(e) => setExportCode(e.target.value)}\n                placeholder=\"点击生成按钮导出当前配色\"\n                className=\"font-mono text-xs\"\n                rows={6}\n                readOnly\n              />\n            </div>\n\n            <div>\n              <div className=\"flex items-center justify-between mb-2\">\n                <h3 className=\"text-sm font-semibold\">导入配色</h3>\n                <Button variant=\"outline\" size=\"sm\" onClick={handleImport} disabled={!importCode.trim()}>\n                  <Upload className=\"h-4 w-4 mr-1\" />\n                  导入\n                </Button>\n              </div>\n              <Textarea\n                value={importCode}\n                onChange={(e) => setImportCode(e.target.value)}\n                placeholder=\"粘贴配色代码...\"\n                className=\"font-mono text-xs\"\n                rows={6}\n              />\n            </div>\n          </TabsContent>\n        </Tabs>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/writing/browser-utils.ts",
    "content": "import { DirTree } from '@/stores/article'\n\nexport function normalizePath(path: string) {\n  return path.replace(/\\\\/g, '/')\n}\n\nexport function parentPath(path: string) {\n  if (!path.includes('/')) return ''\n  return path.split('/').slice(0, -1).join('/')\n}\n\nexport function isMarkdownFile(node: DirTree) {\n  return node.isFile && node.name.toLowerCase().endsWith('.md')\n}\n\nexport function getNodeByPath(tree: DirTree[], path: string): DirTree | null {\n  if (!path) return null\n  const parts = normalizePath(path).split('/').filter(Boolean)\n  let nodes = tree\n  let current: DirTree | null = null\n\n  for (const part of parts) {\n    const next = nodes.find((node) => node.isDirectory && node.name === part)\n    if (!next) return null\n    current = next\n    nodes = next.children || []\n  }\n\n  return current\n}\n\nexport function getChildrenByPath(tree: DirTree[], path: string): DirTree[] {\n  if (!path) return tree\n  const node = getNodeByPath(tree, path)\n  return node?.children || []\n}\n"
  },
  {
    "path": "src/app/mobile/writing/custom-header.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { BaseDirectory, exists, mkdir, remove, rename as fsRename, stat, writeTextFile } from '@tauri-apps/plugin-fs'\nimport { confirm } from '@tauri-apps/plugin-dialog'\nimport { useTranslations } from 'next-intl'\nimport { ChevronLeft, FilePlus, FileText, FolderPlus, Menu, Pencil, Search, Trash2, Unplug } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerTrigger,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { toast } from '@/hooks/use-toast'\nimport useArticleStore from '@/stores/article'\nimport { getFilePathOptions } from '@/lib/workspace'\nimport { EntryListItem } from './entry-list-item'\nimport { NameInputDialog } from './name-input-dialog'\nimport { BrowserEntry } from './types'\nimport { getChildrenByPath, getNodeByPath, isMarkdownFile, normalizePath, parentPath } from './browser-utils'\nimport { deleteFile } from '@/lib/sync/github'\nimport { deleteFile as deleteGiteeFile } from '@/lib/sync/gitee'\nimport { deleteFile as deleteGitlabFile } from '@/lib/sync/gitlab'\nimport { deleteFile as deleteGiteaFile } from '@/lib/sync/gitea'\nimport { s3Delete } from '@/lib/sync/s3'\nimport { webdavDelete } from '@/lib/sync/webdav'\nimport { RepoNames } from '@/lib/sync/github.types'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\n\nexport function WritingHeader() {\n  const t = useTranslations('record.chat.input.fileLink')\n  const tCommon = useTranslations('common')\n  const tFile = useTranslations('article.file')\n  const tContext = useTranslations('article.file.context')\n  const tMobile = useTranslations('article.file.mobile')\n  const tToolbar = useTranslations('article.file.toolbar')\n  const {\n    activeFilePath,\n    setActiveFilePath,\n    readArticle,\n    fileTree,\n    fileTreeLoading,\n    loadFileTree,\n    loadCollapsibleFiles,\n    loadFolderRemoteFiles,\n    setCollapsibleList,\n  } = useArticleStore()\n\n  const [drawerOpen, setDrawerOpen] = useState(false)\n  const [searchQuery, setSearchQuery] = useState('')\n  const [currentDir, setCurrentDir] = useState('')\n  const [folderLoading, setFolderLoading] = useState(false)\n  const [entryMetaMap, setEntryMetaMap] = useState<Record<string, { modifiedAt?: string; size?: number }>>({})\n\n  const [createType, setCreateType] = useState<'file' | 'folder' | null>(null)\n  const [createName, setCreateName] = useState('')\n  const [creating, setCreating] = useState(false)\n\n  const [renameTarget, setRenameTarget] = useState<BrowserEntry | null>(null)\n  const [renameName, setRenameName] = useState('')\n  const [renaming, setRenaming] = useState(false)\n\n  const normalizedActivePath = normalizePath(activeFilePath)\n  const fileName = activeFilePath\n    ? activeFilePath.split('/').pop() || activeFilePath\n    : tCommon('defaultFileName')\n\n  const currentDirLabel = useMemo(() => {\n    if (!currentDir) return tMobile('root')\n    return currentDir.split('/').pop() || currentDir\n  }, [currentDir, tMobile])\n\n  const currentFolderNode = useMemo(() => getNodeByPath(fileTree, currentDir), [fileTree, currentDir])\n\n  const rawEntries = useMemo(() => {\n    const children = getChildrenByPath(fileTree, currentDir)\n    return children\n      .filter((node) => node.isDirectory || isMarkdownFile(node))\n      .sort((a, b) => {\n        if (a.isDirectory && !b.isDirectory) return -1\n        if (!a.isDirectory && b.isDirectory) return 1\n        return a.name.localeCompare(b.name)\n      })\n  }, [fileTree, currentDir])\n\n  const visibleEntries = useMemo(() => {\n    const mapped: BrowserEntry[] = rawEntries.map((node) => {\n      const relativePath = currentDir ? `${currentDir}/${node.name}` : node.name\n      const children = node.children ?? []\n      const fileCount = children.length > 0 ? children.filter((item) => item.isFile).length : undefined\n      const folderCount = children.length > 0 ? children.filter((item) => item.isDirectory).length : undefined\n\n      return {\n        name: node.name,\n        type: node.isDirectory ? 'folder' : 'file',\n        relativePath: normalizePath(relativePath),\n        isLocale: node.isLocale,\n        sha: node.sha,\n        isLoading: node.loading,\n        modifiedAt: node.modifiedAt,\n        size: (node as any).size,\n        fileCount,\n        folderCount,\n      }\n    })\n\n    if (!searchQuery.trim()) return mapped\n    const query = searchQuery.toLowerCase()\n    return mapped.filter((entry) => {\n      return (\n        entry.name.toLowerCase().includes(query) ||\n        entry.relativePath.toLowerCase().includes(query)\n      )\n    })\n  }, [rawEntries, currentDir, searchQuery])\n\n  useEffect(() => {\n    if (!drawerOpen) return\n\n    const localEntries = rawEntries.filter((node) => node.isLocale)\n    if (localEntries.length === 0) return\n\n    const loadEntryMeta = async () => {\n      const updates: Record<string, { modifiedAt?: string; size?: number }> = {}\n\n      for (const node of localEntries) {\n        const relativePath = normalizePath(currentDir ? `${currentDir}/${node.name}` : node.name)\n        const hasModifiedAt = !!node.modifiedAt\n        const hasSize = node.isFile && typeof (node as any).size === 'number'\n\n        if (hasModifiedAt && hasSize) continue\n\n        try {\n          const pathOptions = await getFilePathOptions(relativePath)\n          const fileStat = pathOptions.baseDir\n            ? await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n            : await stat(pathOptions.path)\n\n          updates[relativePath] = {\n            modifiedAt: fileStat.mtime?.toISOString(),\n            size: fileStat.size,\n          }\n        } catch {\n        }\n      }\n\n      if (Object.keys(updates).length > 0) {\n        setEntryMetaMap((prev) => ({ ...prev, ...updates }))\n      }\n    }\n\n    loadEntryMeta()\n  }, [drawerOpen, rawEntries, currentDir])\n\n  const formatDateTime = useCallback((value?: string) => {\n    if (!value) return ''\n    const date = new Date(value)\n    if (Number.isNaN(date.getTime())) return ''\n    return new Intl.DateTimeFormat(undefined, {\n      year: 'numeric',\n      month: '2-digit',\n      day: '2-digit',\n      hour: '2-digit',\n      minute: '2-digit',\n    }).format(date)\n  }, [])\n\n  const formatSize = useCallback((bytes?: number) => {\n    if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return ''\n    if (bytes < 1024) return `${bytes} B`\n    const units = ['KB', 'MB', 'GB', 'TB']\n    let value = bytes / 1024\n    let index = 0\n    while (value >= 1024 && index < units.length - 1) {\n      value /= 1024\n      index += 1\n    }\n    return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[index]}`\n  }, [])\n\n  const getEntrySubtitle = useCallback((entry: BrowserEntry) => {\n    const meta = entryMetaMap[entry.relativePath]\n    const modifiedAt = entry.modifiedAt || meta?.modifiedAt\n    const size = typeof entry.size === 'number' ? entry.size : meta?.size\n\n    if (!entry.isLocale) {\n      if (entry.type === 'file') {\n        const metaParts = [formatDateTime(modifiedAt), formatSize(size)].filter(Boolean)\n        return metaParts.length > 0\n          ? `${tMobile('remoteFileNotPulled')} · ${metaParts.join(' · ')}`\n          : tMobile('remoteFileNotPulled')\n      }\n\n      const remoteFolderSummary = (\n        typeof entry.fileCount === 'number' &&\n        typeof entry.folderCount === 'number'\n      )\n        ? tMobile('folderChildren', { files: entry.fileCount, folders: entry.folderCount })\n        : tMobile('remoteFolderOnly')\n      const modifiedLabel = formatDateTime(modifiedAt)\n      return modifiedLabel ? `${remoteFolderSummary} · ${modifiedLabel}` : remoteFolderSummary\n    }\n\n    if (entry.type === 'file') {\n      const parts = [formatDateTime(modifiedAt), formatSize(size)].filter(Boolean)\n      return parts.length > 0 ? parts.join(' · ') : tMobile('file')\n    }\n\n    const folderSummary = (\n      typeof entry.fileCount === 'number' &&\n      typeof entry.folderCount === 'number'\n    )\n      ? tMobile('folderChildren', { files: entry.fileCount, folders: entry.folderCount })\n      : tMobile('folder')\n\n    const modifiedLabel = formatDateTime(modifiedAt)\n    return modifiedLabel ? `${folderSummary} · ${modifiedLabel}` : folderSummary\n  }, [entryMetaMap, formatDateTime, formatSize, tMobile])\n\n  const isBrowserLoading = fileTreeLoading || folderLoading || !!currentFolderNode?.loading\n\n  const refreshTree = useCallback(async (dir: string) => {\n    await loadFileTree()\n    if (dir) {\n      await setCollapsibleList(dir, true)\n      await loadCollapsibleFiles(dir)\n    }\n  }, [loadFileTree, loadCollapsibleFiles, setCollapsibleList])\n\n  useEffect(() => {\n    if (!drawerOpen) return\n\n    const initialDir = parentPath(normalizedActivePath)\n    setCurrentDir(initialDir)\n    setSearchQuery('')\n\n    const init = async () => {\n      if (fileTree.length === 0) {\n        await loadFileTree()\n      }\n      if (initialDir) {\n        await setCollapsibleList(initialDir, true)\n        await loadCollapsibleFiles(initialDir)\n      }\n    }\n\n    init()\n  }, [drawerOpen, normalizedActivePath, loadFileTree, loadCollapsibleFiles, setCollapsibleList, fileTree.length])\n\n  const ensureLocalFolder = useCallback(async (dir: string) => {\n    if (!dir) return\n    const parentPathOptions = await getFilePathOptions(dir)\n    const parentExists = parentPathOptions.baseDir\n      ? await exists(parentPathOptions.path, { baseDir: parentPathOptions.baseDir })\n      : await exists(parentPathOptions.path)\n\n    if (!parentExists) {\n      if (parentPathOptions.baseDir) {\n        await mkdir(parentPathOptions.path, { baseDir: parentPathOptions.baseDir, recursive: true })\n      } else {\n        await mkdir(parentPathOptions.path, { recursive: true })\n      }\n    }\n  }, [])\n\n  const enterFolder = async (path: string) => {\n    setFolderLoading(true)\n    try {\n      await setCollapsibleList(path, true)\n      await loadCollapsibleFiles(path)\n      await loadFolderRemoteFiles(path)\n      setCurrentDir(path)\n      setSearchQuery('')\n    } finally {\n      setFolderLoading(false)\n    }\n  }\n\n  const openEntry = async (entry: BrowserEntry) => {\n    if (entry.type === 'folder') {\n      await enterFolder(entry.relativePath)\n      return\n    }\n\n    await setActiveFilePath(entry.relativePath)\n    await readArticle(entry.relativePath)\n    setDrawerOpen(false)\n  }\n\n  const handleCreateConfirm = async () => {\n    if (!createType || creating) return\n\n    const rawName = createName.trim()\n    if (!rawName) return\n\n    setCreating(true)\n    try {\n      await ensureLocalFolder(currentDir)\n\n      if (createType === 'file') {\n        let fileNameToCreate = rawName\n        if (!fileNameToCreate.endsWith('.md')) {\n          fileNameToCreate = `${fileNameToCreate}.md`\n        }\n\n        const relativePath = currentDir ? `${currentDir}/${fileNameToCreate}` : fileNameToCreate\n        const pathOptions = await getFilePathOptions(relativePath)\n        const fileExists = pathOptions.baseDir\n          ? await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n          : await exists(pathOptions.path)\n\n        if (!fileExists) {\n          if (pathOptions.baseDir) {\n            await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n          } else {\n            await writeTextFile(pathOptions.path, '')\n          }\n          await refreshTree(currentDir)\n          await setActiveFilePath(relativePath)\n          setDrawerOpen(false)\n        }\n      } else {\n        const relativePath = currentDir ? `${currentDir}/${rawName}` : rawName\n        const pathOptions = await getFilePathOptions(relativePath)\n        const folderExists = pathOptions.baseDir\n          ? await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n          : await exists(pathOptions.path)\n\n        if (!folderExists) {\n          if (pathOptions.baseDir) {\n            await mkdir(pathOptions.path, { baseDir: pathOptions.baseDir, recursive: true })\n          } else {\n            await mkdir(pathOptions.path, { recursive: true })\n          }\n          await refreshTree(currentDir)\n        }\n      }\n\n      setCreateType(null)\n      setCreateName('')\n    } finally {\n      setCreating(false)\n    }\n  }\n\n  const startRename = (entry: BrowserEntry) => {\n    if (!entry.isLocale) {\n      toast({ title: tFile('clipboard.notSupported') })\n      return\n    }\n    const initialName = entry.type === 'file' && entry.name.endsWith('.md')\n      ? entry.name.slice(0, -3)\n      : entry.name\n    setRenameTarget(entry)\n    setRenameName(initialName)\n  }\n\n  const handleRenameConfirm = async () => {\n    if (!renameTarget || renaming) return\n    const rawName = renameName.trim()\n    if (!rawName) return\n\n    setRenaming(true)\n    try {\n      const parent = parentPath(renameTarget.relativePath)\n      const nextName = renameTarget.type === 'file' && !rawName.endsWith('.md')\n        ? `${rawName}.md`\n        : rawName\n      const newRelativePath = parent ? `${parent}/${nextName}` : nextName\n      if (newRelativePath === renameTarget.relativePath) {\n        setRenameTarget(null)\n        setRenameName('')\n        return\n      }\n\n      const oldPathOptions = await getFilePathOptions(renameTarget.relativePath)\n      const newPathOptions = await getFilePathOptions(newRelativePath)\n      const newExists = newPathOptions.baseDir\n        ? await exists(newPathOptions.path, { baseDir: newPathOptions.baseDir })\n        : await exists(newPathOptions.path)\n      if (newExists) {\n        toast({ title: tFile('error.fileExists') })\n        return\n      }\n\n      if (oldPathOptions.baseDir || newPathOptions.baseDir) {\n        await fsRename(oldPathOptions.path, newPathOptions.path, {\n          oldPathBaseDir: oldPathOptions.baseDir || BaseDirectory.AppData,\n          newPathBaseDir: newPathOptions.baseDir || BaseDirectory.AppData,\n        })\n      } else {\n        await fsRename(oldPathOptions.path, newPathOptions.path)\n      }\n\n      if (normalizedActivePath === renameTarget.relativePath) {\n        await setActiveFilePath(newRelativePath)\n      }\n      await refreshTree(currentDir)\n      setRenameTarget(null)\n      setRenameName('')\n    } finally {\n      setRenaming(false)\n    }\n  }\n\n  const handleDelete = async (entry: BrowserEntry) => {\n    if (!entry.isLocale) {\n      toast({ title: tFile('clipboard.notSupported') })\n      return\n    }\n\n    const ok = await confirm(\n      entry.type === 'folder'\n        ? tContext('confirmDelete', { name: entry.name })\n        : `${tContext('deleteLocalFile')}?`,\n      {\n      title: entry.name,\n      kind: 'warning',\n      }\n    )\n    if (!ok) return\n\n    const pathOptions = await getFilePathOptions(entry.relativePath)\n    if (entry.type === 'folder') {\n      if (pathOptions.baseDir) {\n        await remove(pathOptions.path, { baseDir: pathOptions.baseDir, recursive: true })\n      } else {\n        await remove(pathOptions.path, { recursive: true })\n      }\n      if (normalizedActivePath.startsWith(`${entry.relativePath}/`)) {\n        await setActiveFilePath('')\n      }\n    } else {\n      if (pathOptions.baseDir) {\n        await remove(pathOptions.path, { baseDir: pathOptions.baseDir })\n      } else {\n        await remove(pathOptions.path)\n      }\n      if (normalizedActivePath === entry.relativePath) {\n        await setActiveFilePath('')\n      }\n    }\n    await refreshTree(currentDir)\n  }\n\n  const handleDeleteSyncFile = async (entry: BrowserEntry) => {\n    if (entry.type !== 'file' || !entry.sha) return\n\n    const ok = await confirm(`${tContext('deleteSyncFile')}?`, {\n      title: entry.name,\n      kind: 'warning',\n    })\n    if (!ok) return\n\n    const store = await Store.load('store.json')\n    const backupMethod = await store.get<'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'>('primaryBackupMethod') || 'github'\n\n    let success = false\n    switch (backupMethod) {\n      case 'github': {\n        const result = await deleteFile({ path: entry.relativePath, sha: entry.sha, repo: RepoNames.sync })\n        success = !!result\n        break\n      }\n      case 'gitee': {\n        const result = await deleteGiteeFile({ path: entry.relativePath, sha: entry.sha, repo: RepoNames.sync })\n        success = result !== false\n        break\n      }\n      case 'gitlab': {\n        const result = await deleteGitlabFile({ path: entry.relativePath, sha: entry.sha, repo: RepoNames.sync })\n        success = !!result\n        break\n      }\n      case 'gitea': {\n        const result = await deleteGiteaFile({ path: entry.relativePath, sha: entry.sha, repo: RepoNames.sync })\n        success = !!result\n        break\n      }\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          success = await s3Delete(s3Config, entry.relativePath)\n        }\n        break\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          success = await webdavDelete(webdavConfig, entry.relativePath)\n        }\n        break\n      }\n    }\n\n    if (!success) {\n      toast({\n        title: tContext('delete'),\n        description: tContext('deleteSyncFileError'),\n        variant: 'destructive',\n      })\n      return\n    }\n\n    await refreshTree(currentDir)\n    toast({\n      title: tContext('delete'),\n      description: tContext('deleteSyncFileSuccess'),\n    })\n  }\n\n  return (\n    <div className=\"mobile-page-header w-full flex items-center justify-between border-b px-4 text-sm\">\n      <div className=\"flex items-center gap-2 truncate max-w-[70%]\">\n        <FileText className=\"h-4 w-4\" />\n        <span className=\"font-medium truncate\">{fileName}</span>\n      </div>\n\n      <Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>\n        <DrawerTrigger asChild>\n          <Button variant=\"ghost\" size=\"icon\">\n            <Menu className=\"h-5 w-5\" />\n            <span className=\"sr-only\">{tMobile('openFiles')}</span>\n          </Button>\n        </DrawerTrigger>\n        <DrawerContent className=\"h-[85%]\">\n          <DrawerHeader>\n            <div className=\"flex items-center gap-2 min-w-0\">\n              {currentDir !== '' ? (\n                <Button variant=\"ghost\" size=\"icon\" onClick={() => setCurrentDir(parentPath(currentDir))}>\n                  <ChevronLeft className=\"size-4\" />\n                </Button>\n              ) : (\n                <div className=\"size-9\" />\n              )}\n              <DrawerTitle className=\"truncate\">{currentDirLabel}</DrawerTitle>\n            </div>\n          </DrawerHeader>\n          <div className=\"px-4 pb-4 h-full flex flex-col overflow-hidden\">\n            <div className=\"flex items-center gap-2 mb-3\">\n              <div className=\"relative flex-1\">\n                <Search className=\"size-4 text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2\" />\n                <Input\n                  value={searchQuery}\n                  onChange={(event) => setSearchQuery(event.target.value)}\n                  placeholder={t('searchPlaceholder')}\n                  className=\"h-9 pl-8\"\n                />\n              </div>\n              <Button\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"h-9 w-9 shrink-0\"\n                onClick={() => {\n                  setCreateType('file')\n                  setCreateName('')\n                }}\n                title={tToolbar('newArticle')}\n                aria-label={tToolbar('newArticle')}\n              >\n                <FilePlus className=\"size-4\" />\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"h-9 w-9 shrink-0\"\n                onClick={() => {\n                  setCreateType('folder')\n                  setCreateName('')\n                }}\n                title={tToolbar('newFolder')}\n                aria-label={tToolbar('newFolder')}\n              >\n                <FolderPlus className=\"size-4\" />\n              </Button>\n            </div>\n\n            <div className=\"flex-1 overflow-y-auto\">\n              {isBrowserLoading ? (\n                <div className=\"text-sm text-muted-foreground py-8 text-center\">{t('loading')}</div>\n              ) : visibleEntries.length === 0 ? (\n                <div className=\"text-sm text-muted-foreground py-8 text-center\">\n                  {searchQuery.trim() ? t('noFiles') : tFile('mobile.emptyDir')}\n                </div>\n              ) : (\n                <div className=\"space-y-2\">\n                  {visibleEntries.map((entry) => (\n                    <EntryListItem\n                      key={entry.relativePath}\n                      entry={entry}\n                      isActive={entry.type === 'file' && normalizedActivePath === entry.relativePath}\n                      onOpen={openEntry}\n                      remoteLabel={tMobile('remote')}\n                      subtitle={getEntrySubtitle(entry)}\n                      actions={[\n                        {\n                          key: 'rename',\n                          label: tContext('rename'),\n                          icon: <Pencil className=\"size-4\" />,\n                          onClick: () => startRename(entry),\n                          disabled: !entry.isLocale,\n                          variant: 'outline',\n                        },\n                        ...(entry.type === 'file' && entry.sha ? [{\n                          key: 'delete-sync',\n                          label: tContext('deleteSyncFile'),\n                          icon: <Unplug className=\"size-4\" />,\n                          onClick: () => handleDeleteSyncFile(entry),\n                          disabled: !entry.sha,\n                          variant: 'outline' as const,\n                        }] : []),\n                        {\n                          key: 'delete',\n                          label: entry.type === 'file' ? tContext('deleteLocalFile') : tContext('delete'),\n                          icon: <Trash2 className=\"size-4\" />,\n                          onClick: () => handleDelete(entry),\n                          disabled: !entry.isLocale,\n                          variant: 'destructive',\n                        },\n                      ]}\n                    />\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n        </DrawerContent>\n      </Drawer>\n\n      <NameInputDialog\n        open={createType !== null}\n        title={createType === 'file' ? tToolbar('newArticle') : tToolbar('newFolder')}\n        placeholder={createType === 'file' ? tMobile('filePlaceholder') : tMobile('folderPlaceholder')}\n        confirmText={tFile('mobile.create')}\n        cancelText={tFile('mobile.cancel')}\n        value={createName}\n        loading={creating}\n        onChange={setCreateName}\n        onConfirm={handleCreateConfirm}\n        onOpenChange={(open) => {\n          if (!open) {\n            setCreateType(null)\n            setCreateName('')\n          }\n        }}\n      />\n\n      <NameInputDialog\n        open={renameTarget !== null}\n        title={tContext('rename')}\n        confirmText={tFile('mobile.save')}\n        cancelText={tFile('mobile.cancel')}\n        value={renameName}\n        loading={renaming}\n        onChange={setRenameName}\n        onConfirm={handleRenameConfirm}\n        onOpenChange={(open) => {\n          if (!open) {\n            setRenameTarget(null)\n            setRenameName('')\n          }\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/writing/entry-list-item.tsx",
    "content": "'use client'\n\nimport { ReactNode, useRef, useState } from 'react'\nimport { Cloud, FileText, Folder } from 'lucide-react'\nimport { BrowserEntry } from './types'\nimport { Button } from '@/components/ui/button'\n\ntype EntryAction = {\n  key: string\n  label: string\n  icon: ReactNode\n  onClick: () => void | Promise<void>\n  disabled?: boolean\n  variant?: 'default' | 'outline' | 'destructive'\n}\n\ninterface EntryListItemProps {\n  entry: BrowserEntry\n  isActive: boolean\n  onOpen: (entry: BrowserEntry) => void\n  actions: EntryAction[]\n  remoteLabel: string\n  subtitle?: string\n}\n\nexport function EntryListItem({\n  entry,\n  isActive,\n  onOpen,\n  actions,\n  remoteLabel,\n  subtitle,\n}: EntryListItemProps) {\n  const touchStartXRef = useRef(0)\n  const touchStartYRef = useRef(0)\n  const isSwipingRef = useRef(false)\n  const [translateX, setTranslateX] = useState(0)\n  const [opened, setOpened] = useState(false)\n\n  const actionWidth = actions.length * 60\n\n  function handleTouchStart(e: React.TouchEvent<HTMLDivElement>) {\n    if (actions.length === 0) return\n    const touch = e.touches[0]\n    touchStartXRef.current = touch.clientX\n    touchStartYRef.current = touch.clientY\n    isSwipingRef.current = false\n  }\n\n  function handleTouchMove(e: React.TouchEvent<HTMLDivElement>) {\n    if (actions.length === 0) return\n    const touch = e.touches[0]\n    const deltaX = touch.clientX - touchStartXRef.current\n    const deltaY = touch.clientY - touchStartYRef.current\n\n    if (!isSwipingRef.current) {\n      if (Math.abs(deltaX) < 8) return\n      if (Math.abs(deltaX) <= Math.abs(deltaY)) return\n      isSwipingRef.current = true\n    }\n\n    e.preventDefault()\n    const maxLeft = -actionWidth\n    const base = opened ? maxLeft : 0\n    const next = Math.max(maxLeft, Math.min(0, base + deltaX))\n    setTranslateX(next)\n  }\n\n  function handleTouchEnd() {\n    if (actions.length === 0) return\n    const maxLeft = -actionWidth\n    const shouldOpen = translateX < maxLeft / 2\n    setOpened(shouldOpen)\n    setTranslateX(shouldOpen ? maxLeft : 0)\n    isSwipingRef.current = false\n  }\n\n  return (\n    <div className=\"relative overflow-hidden rounded-md bg-background\">\n      {actions.length > 0 && (\n        <div className=\"absolute inset-y-0 right-0 flex items-center gap-2 px-2\">\n          {actions.map((action) => (\n            <Button\n              key={action.key}\n              type=\"button\"\n              variant={action.variant || 'outline'}\n              disabled={action.disabled}\n              size=\"icon\"\n              className=\"size-11 rounded-xl shadow-sm\"\n              onClick={async () => {\n                setOpened(false)\n                setTranslateX(0)\n                await action.onClick()\n              }}\n              aria-label={action.label}\n              title={action.label}\n            >\n              {action.icon}\n              <span className=\"sr-only\">{action.label}</span>\n            </Button>\n          ))}\n        </div>\n      )}\n      <div\n        className={`w-full text-left rounded-md border px-3 py-2 active:bg-accent transition-transform duration-200 ease-out ${\n          isActive ? 'border-primary bg-background shadow-sm' : 'bg-background'\n        }`}\n        style={{ transform: `translateX(${translateX}px)` }}\n        onTouchStart={handleTouchStart}\n        onTouchMove={handleTouchMove}\n        onTouchEnd={handleTouchEnd}\n      >\n        <button\n          type=\"button\"\n          onClick={() => {\n            if (opened) {\n              setOpened(false)\n              setTranslateX(0)\n              return\n            }\n            onOpen(entry)\n          }}\n          className=\"w-full min-w-0 text-left\"\n        >\n          <div className=\"flex items-center gap-2\">\n            {entry.type === 'folder' ? (\n              <Folder className=\"size-4 text-muted-foreground shrink-0\" />\n            ) : (\n              <FileText className=\"size-4 text-muted-foreground shrink-0\" />\n            )}\n            <p className=\"text-sm font-medium truncate flex-1 min-w-0\">{entry.name}</p>\n            {!entry.isLocale && (\n              <span\n                className=\"inline-flex items-center shrink-0 text-sky-600 dark:text-sky-400\"\n                title={remoteLabel}\n                aria-label={remoteLabel}\n              >\n                <Cloud className=\"size-4 stroke-[2.25]\" />\n              </span>\n            )}\n          </div>\n          {subtitle && (\n            <p className=\"text-xs text-muted-foreground truncate mt-1\">{subtitle}</p>\n          )}\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/writing/mobile-editor.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useCallback, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { TipTapEditor } from '@/app/core/main/editor/markdown/tiptap-editor'\nimport { Loader2 } from 'lucide-react'\nimport useArticleStore from '@/stores/article'\nimport emitter from '@/lib/emitter'\nimport { exists, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\n\nexport function MobileEditor() {\n  const tEditor = useTranslations('editor')\n  const { setCurrentArticle, activeFilePath } = useArticleStore()\n\n  const [content, setContent] = useState('')\n  const [isLoading, setIsLoading] = useState(false)\n  const [isEditorReady, setIsEditorReady] = useState(false)\n\n  const activePathRef = useRef<string>('')\n  const contentRef = useRef<string>('')\n  const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)\n  const isSavingRef = useRef(false)\n\n  // 监听 activeFilePath 变化\n  useEffect(() => {\n    if (activeFilePath && activeFilePath !== activePathRef.current) {\n      activePathRef.current = activeFilePath\n      loadFile(activeFilePath)\n    } else if (!activeFilePath && activePathRef.current) {\n      activePathRef.current = ''\n      setContent('')\n      contentRef.current = ''\n      setIsLoading(false)\n      setIsEditorReady(false)\n    }\n  }, [activeFilePath])\n\n  // 加载文件内容\n  const loadFile = useCallback(async (path: string) => {\n    if (!path) return\n\n    setIsLoading(true)\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(path)\n      let fileContent = ''\n\n      if (workspace.isCustom) {\n        const fileExists = await exists(pathOptions.path)\n        if (fileExists) {\n          fileContent = await readTextFile(pathOptions.path)\n        }\n      } else {\n        const fileExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        if (fileExists) {\n          fileContent = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n      }\n\n      setContent(fileContent)\n      contentRef.current = fileContent\n      setCurrentArticle(fileContent)\n    } catch {\n      setContent('')\n      contentRef.current = ''\n      setCurrentArticle('')\n    } finally {\n      setIsLoading(false)\n    }\n  }, [setCurrentArticle])\n\n  // 保存文件\n  const doSave = useCallback(async () => {\n    const path = activePathRef.current\n    const newContent = contentRef.current\n\n    if (!path || isSavingRef.current || !isEditorReady) {\n      return\n    }\n\n    isSavingRef.current = true\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(path)\n\n      if (workspace.isCustom) {\n        await writeTextFile(pathOptions.path, newContent)\n      } else {\n        await writeTextFile(pathOptions.path, newContent, { baseDir: pathOptions.baseDir })\n      }\n\n      setCurrentArticle(newContent)\n    } finally {\n      isSavingRef.current = false\n    }\n  }, [setCurrentArticle, isEditorReady])\n\n  // 处理内容变化\n  const handleContentChange = useCallback((newContent: string) => {\n    setContent(newContent)\n    contentRef.current = newContent\n\n    if (saveTimeoutRef.current) {\n      clearTimeout(saveTimeoutRef.current)\n    }\n\n    saveTimeoutRef.current = setTimeout(() => {\n      doSave()\n    }, 500)\n  }, [doSave])\n\n  // 处理编辑器就绪\n  const handleEditorReady = useCallback(() => {\n    setIsEditorReady(true)\n  }, [])\n\n  // 处理引用到聊天\n  const handleQuoteToChat = useCallback(() => {\n    emitter.emit('get-quote-from-editor')\n  }, [])\n\n  // 清理定时器\n  useEffect(() => {\n    return () => {\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current)\n      }\n    }\n  }, [])\n\n  // 显示加载状态\n  if (isLoading) {\n    return (\n      <div className=\"flex-1 flex items-center justify-center\">\n        <Loader2 className=\"size-8 animate-spin text-muted-foreground\" />\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex-1 relative w-full h-full flex flex-col\">\n      <TipTapEditor\n        initialContent={content}\n        onChange={handleContentChange}\n        placeholder={tEditor('placeholder')}\n        activeFilePath={activeFilePath}\n        onQuoteToChat={handleQuoteToChat}\n        onReady={handleEditorReady}\n      />\n    </div>\n  )\n}\n\nexport default MobileEditor\n"
  },
  {
    "path": "src/app/mobile/writing/name-input-dialog.tsx",
    "content": "'use client'\n\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\n\ninterface NameInputDialogProps {\n  open: boolean\n  title: string\n  placeholder?: string\n  confirmText: string\n  cancelText: string\n  value: string\n  loading?: boolean\n  onChange: (value: string) => void\n  onConfirm: () => void\n  onOpenChange: (open: boolean) => void\n}\n\nexport function NameInputDialog({\n  open,\n  title,\n  placeholder,\n  confirmText,\n  cancelText,\n  value,\n  loading = false,\n  onChange,\n  onConfirm,\n  onOpenChange,\n}: NameInputDialogProps) {\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"w-[92vw] max-w-sm p-4\">\n        <DialogHeader>\n          <DialogTitle>{title}</DialogTitle>\n        </DialogHeader>\n        <Input\n          value={value}\n          onChange={(event) => onChange(event.target.value)}\n          placeholder={placeholder}\n          onKeyDown={(event) => {\n            if (event.key === 'Enter') {\n              event.preventDefault()\n              onConfirm()\n            }\n          }}\n          autoFocus\n        />\n        <DialogFooter className=\"flex-row justify-end gap-2 sm:space-x-0\">\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => onOpenChange(false)}\n          >\n            {cancelText}\n          </Button>\n          <Button\n            size=\"sm\"\n            onClick={onConfirm}\n            disabled={loading || !value.trim()}\n          >\n            {confirmText}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/app/mobile/writing/page.tsx",
    "content": "'use client'\n\nimport { MobileEditor } from './mobile-editor'\nimport { WritingHeader } from './custom-header'\nimport useArticleStore from '@/stores/article'\nimport { useEffect } from 'react'\n\nexport default function Writing() {\n  const { initCollapsibleList } = useArticleStore()\n\n  useEffect(() => {\n    initCollapsibleList()\n  }, [initCollapsibleList])\n\n  return (\n    <div id=\"mobile-writing\" className='w-full h-full flex flex-col'>\n      <WritingHeader />\n      <div className='flex-1 overflow-hidden'>\n        <MobileEditor />\n      </div>\n    </div>\n  )\n}"
  },
  {
    "path": "src/app/mobile/writing/types.ts",
    "content": "export type BrowserEntry = {\n  name: string\n  type: 'folder' | 'file'\n  relativePath: string\n  isLocale: boolean\n  isLoading?: boolean\n  sha?: string\n  modifiedAt?: string\n  size?: number\n  fileCount?: number\n  folderCount?: number\n}\n"
  },
  {
    "path": "src/app/model-config.ts",
    "content": "import { AiConfig } from '@/app/core/setting/config'\n\nexport const noteGenDefaultModels: AiConfig[] = [\n  {\n    \"apiKey\": \"sk-1eaNsBvrfrF4hpwdo6AiQlFzcEtZK7GUpBlOcg03Dm3xunbQ\",\n    \"baseURL\": \"https://api.notegen.top/v1\",\n    \"key\": \"note-gen-free\",\n    \"title\": \"NoteGen Free\",\n    \"models\": [\n      {\n        \"id\": \"note-gen-chat\",\n        \"model\": \"Qwen/Qwen3-8B\",\n        \"modelType\": \"chat\",\n        \"temperature\": 0.7,\n        \"topP\": 1,\n        \"enableStream\": true\n      },\n      {\n        \"id\": \"note-gen-embedding\", \n        \"model\": \"BAAI/bge-m3\",\n        \"modelType\": \"embedding\",\n        \"temperature\": 0.7,\n        \"topP\": 1\n      },\n      {\n        \"id\": \"note-gen-vlm\",\n        \"model\": \"THUDM/GLM-4.1V-9B-Thinking\", \n        \"modelType\": \"chat\",\n        \"temperature\": 0.7,\n        \"topP\": 1,\n        \"enableStream\": true\n      }\n    ]\n  }\n]\n\nexport const noteGenModelKeys = ['note-gen-free', 'note-gen-limited', 'note-gen-chat', 'note-gen-embedding', 'note-gen-vlm']\n"
  },
  {
    "path": "src/app/not-found.tsx",
    "content": "'use client';\n\nimport { useEffect, useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { Button } from '@/components/ui/button';\nimport { isMobileDevice } from '@/lib/check';\nimport { Store } from '@tauri-apps/plugin-store';\n\nexport default function NotFound() {\n  const router = useRouter();\n  const [countdown, setCountdown] = useState(5);\n\n  async function clearRouteStore() {\n    const store = await Store.load('store.json');\n    await store.delete('lastSettingPage')\n    await store.delete('lastRecordPage')\n  }\n\n  useEffect(() => {\n    clearRouteStore()\n  }, [])\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      router.push(isMobileDevice() ? '/mobile/chat' : '/core/main');\n    }, 5000);\n\n    // Update countdown timer every second\n    const countdownInterval = setInterval(() => {\n      setCountdown((prevCount) => {\n        if (prevCount <= 1) {\n          clearInterval(countdownInterval);\n          return 0;\n        }\n        return prevCount - 1;\n      });\n    }, 1000);\n\n    // Cleanup on component unmount\n    return () => {\n      clearTimeout(timer);\n      clearInterval(countdownInterval);\n    };\n  }, [router]);\n\n  return (\n    <div className=\"flex flex-col items-center justify-center min-h-screen p-4\">\n      <h1 className=\"text-3xl font-bold mb-4\">404 - Page Not Found</h1>\n      <div className=\"text-center\">\n        <p className=\"mb-6\">Redirecting to the {isMobileDevice() ? 'Chat' : 'Record'} page in {countdown} seconds...</p>\n        <Button onClick={() => router.push(isMobileDevice() ? '/mobile/chat' : '/core/main')}>Go to {isMobileDevice() ? 'Chat' : 'Record'} Page Now</Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/app/page.tsx",
    "content": "'use client'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { useRouter  } from 'next/navigation'\nimport { useEffect } from 'react'\nimport { isMobileDevice } from '@/lib/check'\n\nexport default function Home() {\n  const router = useRouter()\n  async function init() {\n    const store = await Store.load('store.json')\n    let currentPage = await store.get<string>('currentPage')\n    \n    if (isMobileDevice()) {\n      // 移动端逻辑\n      if (currentPage?.includes('/mobile')) {\n        router.push(currentPage || '/mobile/chat')\n      } else {\n        router.push('/mobile/chat')\n      }\n    } else {\n      // PC 端逻辑：将旧路径重定向到新的 /core/main\n      if (currentPage === '/core/article' || currentPage === '/core/record') {\n        currentPage = '/core/main'\n        await store.set('currentPage', '/core/main')\n        await store.save()\n      }\n      \n      if (!currentPage?.includes('/mobile')) {\n        router.push(currentPage || '/core/main')\n      } else {\n        router.push('/core/main')\n      }\n    }\n  }\n  useEffect(() => {\n    init()\n  }, [])\n}\n"
  },
  {
    "path": "src/components/activity/activity-day-detail.tsx",
    "content": "'use client'\n\nimport { Badge } from '@/components/ui/badge'\nimport type { ActivityDaySummary, ActivityEntry } from '@/lib/activity/types'\n\ninterface ActivityDayDetailProps {\n  day?: ActivityDaySummary\n  compact?: boolean\n  labels: {\n    empty: string\n    records: string\n    writing: string\n    chats: string\n  }\n}\n\nconst badgeClassMap = {\n  record: 'border-rose-200 bg-rose-100 text-rose-700 dark:border-rose-900 dark:bg-rose-950/70 dark:text-rose-200',\n  writing: 'border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/70 dark:text-emerald-200',\n  chat: 'border-sky-200 bg-sky-100 text-sky-700 dark:border-sky-900 dark:bg-sky-950/70 dark:text-sky-200',\n} as const\n\nfunction getSourceLabel(source: ActivityEntry['source'], labels: ActivityDayDetailProps['labels']) {\n  return {\n    record: labels.records,\n    chat: labels.chats,\n    writing: labels.writing,\n  }[source]\n}\n\nfunction renderSourceBadge(entry: ActivityEntry, labels: ActivityDayDetailProps['labels']) {\n  const label = getSourceLabel(entry.source, labels)\n\n  return (\n    <Badge\n      variant=\"outline\"\n      className={`shrink-0 whitespace-nowrap border capitalize ${badgeClassMap[entry.source]}`}\n    >\n      {label}\n    </Badge>\n  )\n}\n\nfunction formatEntryBucket(timestamp: number) {\n  const date = new Date(timestamp)\n  const hours = String(date.getHours()).padStart(2, '0')\n  const minutes = date.getMinutes() >= 30 ? '30' : '00'\n  return `${hours}:${minutes}`\n}\n\nfunction formatEntryTime(timestamp: number) {\n  const date = new Date(timestamp)\n  const hours = String(date.getHours()).padStart(2, '0')\n  const minutes = String(date.getMinutes()).padStart(2, '0')\n  return `${hours}:${minutes}`\n}\n\nfunction normalizeText(value?: string) {\n  return value?.replace(/\\s+/g, ' ').trim() || ''\n}\n\nfunction getEntryBodyText(entry: ActivityEntry) {\n  if (entry.source === 'writing') {\n    return normalizeText(entry.title || entry.path || entry.description)\n  }\n\n  return normalizeText(entry.description || entry.title)\n}\n\nfunction getWritingMergeKey(entry: ActivityEntry) {\n  return normalizeText(entry.path || entry.title || entry.description)\n}\n\nfunction dedupeGroupEntries(entries: ActivityEntry[]) {\n  const dedupedEntries: ActivityEntry[] = []\n  const writingKeys = new Set<string>()\n\n  for (const entry of entries) {\n    if (entry.source !== 'writing') {\n      dedupedEntries.push(entry)\n      continue\n    }\n\n    const mergeKey = getWritingMergeKey(entry)\n    if (writingKeys.has(mergeKey)) {\n      continue\n    }\n\n    writingKeys.add(mergeKey)\n    dedupedEntries.push(entry)\n  }\n\n  return dedupedEntries\n}\n\nfunction groupEntriesByBucket(entries: ActivityEntry[]) {\n  const groups = new Map<string, ActivityEntry[]>()\n\n  for (const entry of entries) {\n    const bucket = formatEntryBucket(entry.timestamp)\n    const nextEntries = groups.get(bucket) || []\n    nextEntries.push(entry)\n    groups.set(bucket, nextEntries)\n  }\n\n  return Array.from(groups.entries()).map(([bucket, groupEntries]) => ({\n    bucket,\n    entries: dedupeGroupEntries(groupEntries),\n  }))\n}\n\nexport function ActivityDayDetail({ day, compact = false, labels }: ActivityDayDetailProps) {\n  const hourGroups = day ? groupEntriesByBucket(day.entries) : []\n\n  return (\n    <section className=\"space-y-4\">\n      <div className=\"flex items-center justify-between gap-3\">\n        <h3 className=\"text-base font-semibold\">{day?.day || new Date().toISOString().slice(0, 10)}</h3>\n        {day ? (\n          <div className=\"flex flex-wrap items-center justify-end gap-2\">\n            <Badge variant=\"outline\" className={`border ${badgeClassMap.record}`}>\n              {labels.records}: {day.counts.record}\n            </Badge>\n            <Badge variant=\"outline\" className={`border ${badgeClassMap.writing}`}>\n              {labels.writing}: {day.counts.writing}\n            </Badge>\n            <Badge variant=\"outline\" className={`border ${badgeClassMap.chat}`}>\n              {labels.chats}: {day.counts.chat}\n            </Badge>\n          </div>\n        ) : null}\n      </div>\n      <div className=\"space-y-1\">\n        {!day ? (\n          <p className=\"text-sm text-muted-foreground\">{labels.empty}</p>\n        ) : null}\n      </div>\n      {day ? (\n        <div className=\"space-y-3\">\n          <div className=\"space-y-3\">\n            {hourGroups.map((group) => (\n              <div key={group.bucket} className=\"grid grid-cols-[max-content_0.875rem_minmax(0,1fr)] gap-2\">\n                <div className=\"pt-1 pr-0.5 text-right text-xs font-semibold tabular-nums text-muted-foreground\">\n                  <span className=\"block whitespace-nowrap\">{group.bucket}</span>\n                </div>\n                <div className=\"relative flex justify-center\">\n                  <div className=\"absolute inset-y-0 w-px bg-border/70\" />\n                  <div className=\"absolute top-2 size-2.5 rounded-full border border-background bg-primary shadow-sm\" />\n                </div>\n                <div className=\"space-y-2\">\n                  {group.entries.map((entry) => (\n                    <div\n                      key={entry.id}\n                      className={compact ? 'rounded-xl bg-muted/35 px-3 py-2.5' : 'rounded-xl border border-border/60 p-3'}\n                    >\n                      <div className=\"space-y-1.5\">\n                        <div className=\"flex items-center gap-2\">\n                          {renderSourceBadge(entry, labels)}\n                          <span className=\"ml-auto text-xs font-medium tabular-nums text-muted-foreground\">\n                            {formatEntryTime(entry.timestamp)}\n                          </span>\n                        </div>\n                        <p className=\"line-clamp-2 text-sm leading-6\">\n                          {getEntryBodyText(entry)}\n                        </p>\n                      </div>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      ) : null}\n    </section>\n  )\n}\n"
  },
  {
    "path": "src/components/activity/activity-drawer.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\n\nimport { Sheet, SheetContent } from '@/components/ui/sheet'\nimport { loadActivityCalendarData } from '@/lib/activity'\nimport type { ActivityCalendarData, ActivityDaySummary } from '@/lib/activity/types'\nimport { ActivityPanel } from './activity-panel'\n\ninterface ActivityDrawerProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function ActivityDrawer({ open, onOpenChange }: ActivityDrawerProps) {\n  const [data, setData] = useState<ActivityCalendarData | null>(null)\n  const [selectedDay, setSelectedDay] = useState<ActivityDaySummary | undefined>(undefined)\n  const [loading, setLoading] = useState(false)\n\n  async function refreshData(resetSelection = false) {\n    setLoading(true)\n    try {\n      const nextData = await loadActivityCalendarData()\n      setData(nextData)\n\n      setSelectedDay((currentSelectedDay) => {\n        if (!resetSelection && currentSelectedDay) {\n          return nextData.days.find((day) => day.day === currentSelectedDay.day) || currentSelectedDay\n        }\n\n        const today = nextData.days.find((day) => day.day === nextData.endDate)\n        const fallback = [...nextData.days].reverse().find((day) => day.totalCount > 0)\n        return today || fallback\n      })\n    } finally {\n      setLoading(false)\n    }\n  }\n\n  useEffect(() => {\n    if (open) {\n      refreshData(true)\n    }\n  }, [open])\n\n  return (\n    <Sheet open={open} onOpenChange={onOpenChange}>\n      <SheetContent side=\"right\" hideCloseButton className=\"top-[36px] w-[452px] p-4 sm:max-w-none\">\n        <div className=\"scrollbar-hide h-full overflow-y-auto\">\n          <ActivityPanel\n            data={data}\n            selectedDay={selectedDay}\n            loading={loading}\n            onSelectDay={setSelectedDay}\n            mode=\"drawer\"\n          />\n        </div>\n      </SheetContent>\n    </Sheet>\n  )\n}\n"
  },
  {
    "path": "src/components/activity/activity-heatmap.tsx",
    "content": "'use client'\n\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\nimport type { ActivityDaySummary, ActivityHeatmapWeek } from '@/lib/activity/types'\n\ninterface ActivityHeatmapProps {\n  weeks: ActivityHeatmapWeek[]\n  selectedDay?: string\n  onSelectDay: (day: ActivityDaySummary) => void\n  compact?: boolean\n  labels: {\n    dayCount: string\n    emptyDay: string\n  }\n}\n\nfunction getIntensityLevel(totalCount: number) {\n  if (totalCount <= 0) return 0\n  if (totalCount <= 5) return 1\n  if (totalCount <= 10) return 2\n  if (totalCount <= 20) return 3\n  return 4\n}\n\nconst LEVEL_CLASSES = [\n  'bg-muted hover:bg-muted/80',\n  'bg-emerald-100 hover:bg-emerald-200 dark:bg-emerald-950/70 dark:hover:bg-emerald-900',\n  'bg-emerald-300 hover:bg-emerald-400 dark:bg-emerald-800/80 dark:hover:bg-emerald-700',\n  'bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600/90 dark:hover:bg-emerald-500',\n  'bg-emerald-700 hover:bg-emerald-800 dark:bg-emerald-400/90 dark:hover:bg-emerald-300',\n] as const\n\nexport function ActivityHeatmap({\n  weeks,\n  selectedDay,\n  onSelectDay,\n  compact = false,\n  labels,\n}: ActivityHeatmapProps) {\n  return (\n    <TooltipProvider>\n      <div className=\"w-full overflow-visible px-1 py-1\">\n        <div className={cn('inline-flex gap-1.5', compact && 'gap-1')}>\n          {weeks.map((week, weekIndex) => (\n            <div key={weekIndex} className={cn('flex flex-col gap-1.5', compact && 'gap-1')}>\n              {week.days.map((day) => {\n                const level = getIntensityLevel(day.totalCount)\n                const isSelected = selectedDay === day.day\n                const tooltipText = day.totalCount > 0\n                  ? `${day.day} · ${day.totalCount} ${labels.dayCount}`\n                  : `${day.day} · ${labels.emptyDay}`\n\n                return (\n                  <Tooltip key={day.day}>\n                    <TooltipTrigger asChild>\n                      <button\n                        type=\"button\"\n                        onClick={() => onSelectDay(day)}\n                        className={cn(\n                          compact ? 'h-3 w-3 rounded-[3px] border border-black/5 transition-colors' : 'h-4 w-4 rounded-[4px] border border-black/5 transition-colors',\n                          LEVEL_CLASSES[level],\n                          isSelected && (compact\n                            ? 'ring-2 ring-primary ring-offset-1 ring-offset-background'\n                            : 'ring-2 ring-primary ring-offset-2 ring-offset-background')\n                        )}\n                        aria-label={tooltipText}\n                      />\n                    </TooltipTrigger>\n                    <TooltipContent side=\"top\">\n                      <p>{tooltipText}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                )\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/activity/activity-legend.tsx",
    "content": "'use client'\n\ninterface ActivityLegendProps {\n  lowLabel: string\n  highLabel: string\n}\n\nconst LEVEL_CLASSES = [\n  'bg-muted',\n  'bg-emerald-100 dark:bg-emerald-950/70',\n  'bg-emerald-300 dark:bg-emerald-800/80',\n  'bg-emerald-500 dark:bg-emerald-600/90',\n  'bg-emerald-700 dark:bg-emerald-400/90',\n] as const\n\nexport function ActivityLegend({ lowLabel, highLabel }: ActivityLegendProps) {\n  return (\n    <div className=\"flex items-center justify-end gap-2 text-xs text-muted-foreground\">\n      <span>{lowLabel}</span>\n      <div className=\"flex items-center gap-1\">\n        {LEVEL_CLASSES.map((className, index) => (\n          <span key={index} className={`h-3 w-3 rounded-[4px] ${className}`} />\n        ))}\n      </div>\n      <span>{highLabel}</span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/activity/activity-panel.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useTranslations } from 'next-intl'\n\nimport { ActivityDayDetail } from '@/components/activity/activity-day-detail'\nimport { ActivityHeatmap } from '@/components/activity/activity-heatmap'\nimport { ActivityLegend } from '@/components/activity/activity-legend'\nimport { CardDescription, CardTitle } from '@/components/ui/card'\nimport { cn } from '@/lib/utils'\nimport type { ActivityCalendarData, ActivityDaySummary } from '@/lib/activity/types'\n\ninterface ActivityPanelProps {\n  data: ActivityCalendarData | null\n  selectedDay?: ActivityDaySummary\n  loading?: boolean\n  onSelectDay: (day: ActivityDaySummary) => void\n  mode?: 'page' | 'drawer'\n}\n\nexport function ActivityPanel({\n  data,\n  selectedDay,\n  loading = false,\n  onSelectDay,\n  mode = 'page',\n}: ActivityPanelProps) {\n  const t = useTranslations('activity')\n\n  const summaryLabels = useMemo(() => ({\n    totalCount: t('summary.totalCount'),\n    activeDays: t('summary.activeDays'),\n    records: t('summary.records'),\n    writing: t('summary.writing'),\n    chats: t('summary.chats'),\n    recordBadge: t('labels.record'),\n    writingBadge: t('labels.writing'),\n    chatBadge: t('labels.chat'),\n  }), [t])\n\n  if (loading && !data) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n        {t('loading')}\n      </div>\n    )\n  }\n\n  if (!data) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n        {t('empty')}\n      </div>\n    )\n  }\n\n  return (\n    <div className={`flex h-full flex-col ${mode === 'page' ? 'gap-6' : 'gap-4'}`}>\n      <div className={cn(mode === 'page' ? '' : 'space-y-4')}>\n        <div className={cn('flex gap-4', mode === 'page' ? 'flex-col md:flex-row md:items-end md:justify-between' : 'flex-col')}>\n          <div className=\"space-y-2\">\n            {mode === 'page' ? (\n              <>\n                <CardTitle className=\"text-2xl font-semibold tracking-tight\">{t('title')}</CardTitle>\n                <CardDescription className=\"max-w-2xl text-sm leading-6\">{t('description')}</CardDescription>\n              </>\n            ) : (\n              <>\n                <h2 className=\"text-lg font-semibold tracking-tight\">{t('drawer.title')}</h2>\n                <p className=\"text-xs leading-5 text-muted-foreground\">{t('drawer.description')}</p>\n              </>\n            )}\n          </div>\n        </div>\n\n        <div className={cn(mode === 'page' ? 'rounded-2xl border border-border/70 p-6 shadow-sm' : 'space-y-4')}>\n          <div className={cn('rounded-2xl bg-muted/30 p-4', mode === 'page' && 'border border-border/70')}>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <div className=\"rounded-xl bg-background/80 p-3\">\n                <p className=\"text-xs text-muted-foreground\">{summaryLabels.totalCount}</p>\n                <p className=\"mt-1 text-lg font-semibold\">{data.totals.totalCount}</p>\n              </div>\n              <div className=\"rounded-xl bg-background/80 p-3\">\n                <p className=\"text-xs text-muted-foreground\">{summaryLabels.activeDays}</p>\n                <p className=\"mt-1 text-lg font-semibold\">{data.totals.activeDays}</p>\n              </div>\n            </div>\n            <div className=\"mt-4 grid grid-cols-3 gap-2\">\n              <div className=\"rounded-xl bg-background/80 p-3\">\n                <p className=\"text-xs text-muted-foreground\">{summaryLabels.records}</p>\n                <p className=\"mt-1 text-lg font-semibold\">{data.totals.recordCount}</p>\n              </div>\n              <div className=\"rounded-xl bg-background/80 p-3\">\n                <p className=\"text-xs text-muted-foreground\">{summaryLabels.writing}</p>\n                <p className=\"mt-1 text-lg font-semibold\">{data.totals.writingCount}</p>\n              </div>\n              <div className=\"rounded-xl bg-background/80 p-3\">\n                <p className=\"text-xs text-muted-foreground\">{summaryLabels.chats}</p>\n                <p className=\"mt-1 text-lg font-semibold\">{data.totals.chatCount}</p>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"space-y-3\">\n            <div className=\"flex items-center justify-between gap-3\">\n              <p className=\"text-base font-semibold\">\n                {t('heatmap.range', { startDate: data.startDate, endDate: data.endDate })}\n              </p>\n              <ActivityLegend\n                lowLabel={t('heatmap.less')}\n                highLabel={t('heatmap.more')}\n              />\n            </div>\n            <ActivityHeatmap\n              weeks={data.weeks}\n              selectedDay={selectedDay?.day}\n              onSelectDay={onSelectDay}\n              compact={mode === 'drawer'}\n              labels={{\n                dayCount: t('heatmap.dayCount'),\n                emptyDay: t('heatmap.emptyDay'),\n              }}\n            />\n          </div>\n        </div>\n      </div>\n\n      <ActivityDayDetail\n        day={selectedDay}\n        compact={mode === 'drawer'}\n        labels={{\n          empty: t('detail.empty'),\n          records: summaryLabels.recordBadge,\n          writing: summaryLabels.writingBadge,\n          chats: summaryLabels.chatBadge,\n        }}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/app-footbar.tsx",
    "content": "'use client'\n\nimport { MessageSquare, Highlighter, SquarePen, Settings, User, Plus } from \"lucide-react\"\nimport { usePathname, useRouter } from 'next/navigation'\nimport { cn } from \"@/lib/utils\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { useTranslations } from 'next-intl'\nimport { useSidebarStore } from \"@/stores/sidebar\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"./ui/avatar\"\nimport useSettingStore from \"@/stores/setting\"\nimport useSyncStore from \"@/stores/sync\"\nimport { UserInfo } from \"@/lib/sync/github.types\"\nimport { getUserInfo } from \"@/lib/sync/github\"\nimport { useEffect, useState } from \"react\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover'\nimport { MobileRecordTools } from '@/components/mobile-record-tools'\n\n// 普通导航按钮组件\ninterface NormalNavButtonProps {\n  item: {\n    title: string\n    url: string\n    icon: React.ComponentType<{ className?: string }>\n  }\n  isActive: boolean\n  onClick: () => void\n}\n\nfunction NormalNavButton({ item, isActive, onClick }: NormalNavButtonProps) {\n  return (\n    <button\n      onClick={onClick}\n      className={cn(\n        \"flex flex-col items-center justify-center w-1/5 py-1 transition-colors relative\",\n        isActive ? \"text-primary\" : \"text-muted-foreground hover:text-primary\"\n      )}\n    >\n      <item.icon className=\"h-5 w-5\" />\n      <span className=\"text-xs mt-0.5\">{item.title}</span>\n      {isActive && (\n        <div className=\"absolute -bottom-1 w-1 h-1 rounded-full bg-primary\" />\n      )}\n    </button>\n  )\n}\n\n// 头像导航按钮组件\ninterface AvatarNavButtonProps {\n  item: {\n    title: string\n    url: string\n  }\n  isActive: boolean\n  avatarUrl: string\n  onClick: () => void\n}\n\nfunction AvatarNavButton({ item, isActive, avatarUrl, onClick }: AvatarNavButtonProps) {\n  return (\n    <button\n      onClick={onClick}\n      className={cn(\n        \"flex flex-col items-center justify-center w-1/5 py-1 transition-colors relative\",\n        isActive ? \"text-primary\" : \"text-muted-foreground hover:text-primary\"\n      )}\n    >\n      <div className=\"flex flex-col items-center\">\n        <Avatar className=\"h-6 w-6\">\n          <AvatarImage \n            src={avatarUrl} \n            alt=\"Profile\" \n          />\n          <AvatarFallback>\n            <User className=\"h-4 w-4\" />\n          </AvatarFallback>\n        </Avatar>\n        <span className=\"text-xs mt-0.5\">{item.title}</span>\n        {isActive && (\n          <div className=\"absolute -bottom-1 w-1 h-1 rounded-full bg-primary\" />\n        )}\n      </div>\n    </button>\n  )\n}\n\nexport function AppFootbar() {\n  const pathname = usePathname()\n  const router = useRouter()\n  const { toggleFileSidebar } = useSidebarStore()\n  const [quickRecordOpen, setQuickRecordOpen] = useState(false)\n  const { \n    githubUsername,\n    accessToken,\n    primaryBackupMethod,\n    giteeAccessToken,\n    gitlabAccessToken,\n    giteaAccessToken,\n    setGithubUsername,\n    setGitlabUsername,\n    setGiteaUsername,\n  } = useSettingStore()\n  const {\n    setUserInfo,\n    setSyncRepoInfo,\n    setGiteeSyncRepoInfo,\n    setGitlabSyncProjectInfo,\n    setGiteeUserInfo,\n    setGitlabUserInfo,\n    setGiteaSyncRepoInfo,\n    setGiteaUserInfo,\n    giteeUserInfo,\n    gitlabUserInfo,\n    giteaUserInfo,\n  } = useSyncStore()\n  const t = useTranslations()\n  \n  // 检查是否有 GitHub、Gitee、Gitlab 或 Gitea 账号，用于显示头像\n  const hasGithubAccount = Boolean(githubUsername && accessToken)\n  const hasGiteeAccount = Boolean(giteeAccessToken)\n  const hasGitlabAccount = Boolean(gitlabAccessToken)\n  const hasGiteaAccount = Boolean(giteaAccessToken)\n  const showAvatar = hasGithubAccount || hasGiteeAccount || hasGitlabAccount || hasGiteaAccount\n\n  // 获取当前主要备份方式的用户信息\n  async function handleGetUserInfo() {\n    try {\n      if (primaryBackupMethod === 'github') {\n        if (accessToken) {\n          setSyncRepoInfo(undefined)\n          const res = await getUserInfo()\n          if (res) {\n            setUserInfo(res.data as UserInfo)\n            setGithubUsername(res.data.login)\n          }\n        }\n      } else if (primaryBackupMethod === 'gitee') {\n        if (giteeAccessToken) {\n          // 获取 Gitee 用户信息\n          setGiteeSyncRepoInfo(undefined)\n          const res = await import('@/lib/sync/gitee').then(module => module.getUserInfo())\n          if (res) {\n            setGiteeUserInfo(res)\n          }\n        }\n      } else if (primaryBackupMethod === 'gitlab') {\n        if (gitlabAccessToken) {\n          // 获取 Gitlab 用户信息\n          setGitlabSyncProjectInfo(undefined)\n          const { getUserInfo } = await import('@/lib/sync/gitlab')\n          const res = await getUserInfo()\n          if (res) {\n            setGitlabUserInfo(res)\n            setGitlabUsername(res.username)\n          }\n        }\n      } else if (primaryBackupMethod === 'gitea') {\n        if (giteaAccessToken) {\n          // 获取 Gitea 用户信息\n          setGiteaSyncRepoInfo(undefined)\n          const { getUserInfo } = await import('@/lib/sync/gitea')\n          const res = await getUserInfo()\n          if (res) {\n            setGiteaUserInfo(res)\n            setGiteaUsername(res.username)\n          }\n        }\n      } else {\n        setUserInfo(undefined)\n        setGiteeUserInfo(undefined)\n        setGitlabUserInfo(undefined)\n        setGiteaUserInfo(undefined)\n      }\n    } catch (err) {\n      console.error('Failed to get user info:', err)\n    }\n  }\n  \n  // 根据主备份方式获取正确的头像地址\n  const getAvatarUrl = () => {\n    switch (primaryBackupMethod) {\n      case 'github':\n        if (hasGithubAccount && githubUsername) {\n          return `https://github.com/${githubUsername}.png`\n        }\n        break\n      case 'gitee':\n        if (hasGiteeAccount && giteeUserInfo?.avatar_url) {\n          return giteeUserInfo.avatar_url\n        }\n        break\n      case 'gitlab':\n        if (hasGitlabAccount && gitlabUserInfo?.avatar_url) {\n          return gitlabUserInfo.avatar_url\n        }\n        break\n      case 'gitea':\n        if (hasGiteaAccount && giteaUserInfo?.avatar_url) {\n          return giteaUserInfo.avatar_url\n        }\n        break\n      default:\n        return ''\n    }\n    return ''\n  }\n\n  const avatarUrl = getAvatarUrl()\n    \n  // 底部导航菜单项\n  const items = [\n    {\n      title: t('navigation.chat'),\n      url: \"/mobile/chat\",\n      icon: MessageSquare,\n    },\n    {\n      title: t('navigation.record'),\n      url: \"/mobile/record\",\n      icon: Highlighter,\n    },\n    {\n      title: t('navigation.quickRecord'),\n      url: \"#quick-record\",\n      icon: Plus,\n      isQuickRecord: true,\n    },\n    {\n      title: t('navigation.write'),\n      url: \"/mobile/writing\",\n      icon: SquarePen,\n    },\n    {\n      title: t('navigation.setting'),\n      url: \"/mobile/setting\",\n      icon: Settings,\n    },\n  ]\n\n  // 处理导航点击事件\n  async function menuHandler(item: typeof items[0]) {\n    if (item.isQuickRecord) {\n      // 快捷记录按钮：打开浮动弹窗\n      setQuickRecordOpen(!quickRecordOpen)\n      return\n    }\n    \n    if (pathname === '/core/article' && item.url === '/core/article') {\n      toggleFileSidebar()\n    } else {\n      router.push(item.url)\n    }\n    const store = await Store.load('store.json')\n    store.set('currentPage', item.url)\n  }\n\n  useEffect(() => {\n    if (accessToken || giteeAccessToken || gitlabAccessToken || giteaAccessToken) {\n      handleGetUserInfo()\n    }\n  }, [accessToken, giteeAccessToken, gitlabAccessToken, giteaAccessToken, primaryBackupMethod])\n\n  return (\n    <div className=\"w-full border-t bg-background h-14 relative\">\n      <div className=\"flex h-full items-center justify-around\">\n        {items.map((item, index) => {\n          // 快捷记录按钮 - 使用 Popover\n          if (item.isQuickRecord) {\n            return (\n              <Popover key={index} open={quickRecordOpen} onOpenChange={setQuickRecordOpen}>\n                <PopoverTrigger asChild>\n                  <button\n                    type=\"button\"\n                    className=\"w-1/5 flex items-center justify-center\"\n                    aria-label={item.title}\n                    title={item.title}\n                  >\n                    <span\n                      className={cn(\n                        \"inline-flex size-11 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-transform active:scale-95\",\n                        quickRecordOpen && \"ring-2 ring-primary/30\"\n                      )}\n                    >\n                      <Plus className=\"size-6\" />\n                    </span>\n                  </button>\n                </PopoverTrigger>\n                <PopoverContent\n                  align=\"center\"\n                  side=\"top\"\n                  sideOffset={10}\n                  collisionPadding={12}\n                  className=\"w-[min(92vw,360px)] rounded-2xl p-3\"\n                >\n                    <MobileRecordTools onClose={() => setQuickRecordOpen(false)} />\n                </PopoverContent>\n              </Popover>\n            )\n          }\n          \n          // 头像按钮（最后一项且有头像）\n          const isAvatarButton = index === items.length - 1 && showAvatar && avatarUrl\n          if (isAvatarButton) {\n            return (\n              <AvatarNavButton\n                key={index}\n                item={item}\n                isActive={pathname === item.url}\n                avatarUrl={avatarUrl}\n                onClick={() => menuHandler(item)}\n              />\n            )\n          }\n          \n          // 普通按钮\n          return (\n            <NormalNavButton\n              key={index}\n              item={item}\n              isActive={pathname === item.url}\n              onClick={() => menuHandler(item)}\n            />\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/app-sidebar.tsx",
    "content": "'use client'\nimport { ImageUp, Search, Settings, SquarePen, X } from \"lucide-react\"\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarHeader,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n} from \"@/components/ui/sidebar\"\nimport { usePathname, useRouter } from 'next/navigation'\nimport AppStatus from \"./app-status\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { PinToggle } from \"./pin-toggle\"\nimport { useTranslations } from 'next-intl'\nimport { useEffect, useState } from \"react\"\nimport useImageStore from \"@/stores/imageHosting\"\n \ninterface AppSidebarProps {\n  onSearchClick?: () => void\n}\n\nexport function AppSidebar({ onSearchClick }: AppSidebarProps) {\n  const pathname = usePathname()\n  const router = useRouter()\n  const t = useTranslations()\n  const { imageRepoUserInfo } = useImageStore()\n  const [items, setItems] = useState([\n    {\n      title: t('navigation.write'),\n      url: \"/core/main\",\n      icon: SquarePen,\n      isActive: true,\n    },\n    {\n      title: t('navigation.search'),\n      url: \"/core/search\",\n      icon: Search,\n    },\n  ])\n\n  async function initGithubImageHosting() {\n    const store = await Store.load('store.json')\n    const githubImageUsername = await store.get<string>('githubImageUsername')\n    const githubImageAccessToken = await store.get<string>('githubImageAccessToken')\n    if (githubImageUsername && githubImageAccessToken && !items.find(item => item.url === '/core/image')) {\n      setItems([...items, {\n        title: t('navigation.githubImageHosting'),\n        url: \"/core/image\",\n        icon: ImageUp,\n      }])\n    }\n  }\n\n  async function menuHandler(item: typeof items[0]) {\n    // 如果是搜索按钮，打开搜索对话框\n    if (item.url === '/core/search') {\n      onSearchClick?.()\n      return\n    }\n\n    // 直接跳转到对应页面\n    router.push(item.url)\n    const store = await Store.load('store.json')\n    store.set('currentPage', item.url)\n  }\n\n  useEffect(() => {\n    initGithubImageHosting()\n  }, [imageRepoUserInfo])\n\n  return (\n    <Sidebar \n      collapsible=\"none\"\n      className=\"!w-[calc(var(--sidebar-width-icon)_+_1px)] border-r h-[calc(100vh-36px)] mt-9\"\n    >\n      <SidebarHeader>\n        <SidebarMenu>\n          <SidebarMenuItem>\n            <AppStatus />\n          </SidebarMenuItem>\n        </SidebarMenu>\n      </SidebarHeader>\n      <SidebarContent>\n        <SidebarGroup>\n          <SidebarGroupContent>\n            <SidebarMenu>\n              {items.map((item) => (\n                <SidebarMenuItem key={item.title}>\n                  <SidebarMenuButton\n                    asChild\n                    disabled={item.url === '#'}\n                    isActive={pathname === item.url}\n                    tooltip={{\n                      children: item.title,\n                      hidden: false,\n                    }}\n                  >\n                    <div className=\"cursor-pointer\" onClick={() => menuHandler(item)}>\n                      <item.icon />\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n              ))}\n            </SidebarMenu>\n          </SidebarGroupContent>\n        </SidebarGroup>\n      </SidebarContent>\n      <SidebarFooter>\n        <PinToggle />\n        <SidebarMenuButton \n          isActive={pathname.includes('/core/setting')} \n          className=\"md:h-8 md:p-0\"\n          tooltip={{\n            children: pathname.includes('/core/setting') ? t('common.back') : t('common.settings'),\n            hidden: false,\n          }}\n          onClick={() => {\n            if (pathname.includes('/core/setting')) {\n              router.push('/core/main')\n            } else {\n              router.push('/core/setting')\n            }\n          }}\n        >\n          <div className=\"flex size-8 items-center justify-center rounded-lg\">\n            {pathname.includes('/core/setting') ? (\n              <X className=\"size-4\" />\n            ) : (\n              <Settings className=\"size-4\" />\n            )}\n          </div>\n        </SidebarMenuButton>\n      </SidebarFooter>\n    </Sidebar>\n  )\n}"
  },
  {
    "path": "src/components/app-status.tsx",
    "content": "import { checkSyncRepoState, getUserInfo } from \"@/lib/sync/github\";\nimport { useEffect } from \"react\";\nimport useSettingStore from \"@/stores/setting\";\nimport { SyncStateEnum, UserInfo } from \"@/lib/sync/github.types\";\nimport useSyncStore from \"@/stores/sync\";\nimport { getSyncRepoName } from \"@/lib/sync/repo-utils\";\n\nexport default function AppStatus() {\n  const { accessToken, giteeAccessToken, gitlabAccessToken, giteaAccessToken, primaryBackupMethod, setGithubUsername, setGitlabUsername, setGiteaUsername } = useSettingStore()\n  const { \n    setUserInfo, \n    setGiteeUserInfo,\n    setGitlabUserInfo,\n    setGiteaUserInfo,\n    setSyncRepoState,\n    setSyncRepoInfo,\n    setGiteeSyncRepoState,\n    setGiteeSyncRepoInfo,\n    setGitlabSyncProjectState,\n    setGitlabSyncProjectInfo,\n    setGiteaSyncRepoState,\n    setGiteaSyncRepoInfo\n  } = useSyncStore()\n\n  // 获取当前主要备份方式的用户信息\n  async function handleGetUserInfo() {\n    try {\n      if (primaryBackupMethod === 'github') {\n        if (accessToken) {\n          setSyncRepoInfo(undefined)\n          setSyncRepoState(SyncStateEnum.checking)\n          const res = await getUserInfo()\n          if (res) {\n            setUserInfo(res.data as UserInfo)\n            setGithubUsername(res.data.login)\n          }\n          await checkGithubRepos()\n        }\n      } else if (primaryBackupMethod === 'gitee') {\n        if (giteeAccessToken) {\n          // 获取 Gitee 用户信息\n          setGiteeSyncRepoInfo(undefined)\n          setGiteeSyncRepoState(SyncStateEnum.checking)\n          const res = await import('@/lib/sync/gitee').then(module => module.getUserInfo())\n          if (res) {\n            setGiteeUserInfo(res)\n          }\n          // 注意：checkGiteeRepos 内部已经包含了 getUserInfo 调用，但这里保留以确保用户信息及时更新\n          await checkGiteeRepos()\n        }\n      } else if (primaryBackupMethod === 'gitlab') {\n        if (gitlabAccessToken) {\n          // 获取 Gitlab 用户信息\n          setGitlabSyncProjectInfo(undefined)\n          setGitlabSyncProjectState(SyncStateEnum.checking)\n          const { getUserInfo } = await import('@/lib/sync/gitlab')\n          const res = await getUserInfo()\n          if (res) {\n            setGitlabUserInfo(res)\n            setGitlabUsername(res.username)\n          }\n          await checkGitlabProjects()\n        }\n      } else if (primaryBackupMethod === 'gitea') {\n        if (giteaAccessToken) {\n          // 获取 Gitea 用户信息\n          setGiteaSyncRepoInfo(undefined)\n          setGiteaSyncRepoState(SyncStateEnum.checking)\n          const { getUserInfo } = await import('@/lib/sync/gitea')\n          const res = await getUserInfo()\n          if (res) {\n            setGiteaUserInfo(res)\n            setGiteaUsername(res.username)\n          }\n          await checkGiteaRepos()\n        }\n      } else {\n        setUserInfo(undefined)\n        setGiteeUserInfo(undefined)\n        setGitlabUserInfo(undefined)\n        setGiteaUserInfo(undefined)\n      }\n    } catch (err) {\n      console.error('Failed to get user info:', err)\n    }\n  }\n\n  // 检查 GitHub 仓库状态（仅检查，不创建）\n  async function checkGithubRepos() {\n    try {\n      // 检查同步仓库状态\n      const githubRepo = await getSyncRepoName('github')\n      const syncRepo = await checkSyncRepoState(githubRepo)\n      if (syncRepo) {\n        setSyncRepoInfo(syncRepo)\n        setSyncRepoState(SyncStateEnum.success)\n      } else {\n        setSyncRepoInfo(undefined)\n        setSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check GitHub repos:', err)\n      setSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n  \n  // 检查 Gitlab 项目状态（仅检查，不创建）\n  async function checkGitlabProjects() {\n    try {\n      const { checkSyncProjectState } = await import('@/lib/sync/gitlab')\n      \n      // 检查同步项目状态\n      const gitlabRepo = await getSyncRepoName('gitlab')\n      const syncProject = await checkSyncProjectState(gitlabRepo)\n      if (syncProject) {\n        setGitlabSyncProjectInfo(syncProject)\n        setGitlabSyncProjectState(SyncStateEnum.success)\n      } else {\n        setGitlabSyncProjectInfo(undefined)\n        setGitlabSyncProjectState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check Gitlab projects:', err)\n      setGitlabSyncProjectState(SyncStateEnum.fail)\n    }\n  }\n  \n  // 检查 Gitea 仓库状态（仅检查，不创建）\n  async function checkGiteaRepos() {\n    try {\n      const { checkSyncRepoState } = await import('@/lib/sync/gitea')\n      \n      // 检查同步仓库状态\n      const giteaRepo = await getSyncRepoName('gitea')\n      const syncRepo = await checkSyncRepoState(giteaRepo)\n      if (syncRepo) {\n        setGiteaSyncRepoInfo(syncRepo)\n        setGiteaSyncRepoState(SyncStateEnum.success)\n      } else {\n        setGiteaSyncRepoInfo(undefined)\n        setGiteaSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check Gitea repos:', err)\n      setGiteaSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n  \n  // 检查 Gitee 仓库状态（仅检查，不创建）\n  async function checkGiteeRepos() {\n    try {\n      const { checkSyncRepoState, getUserInfo } = await import('@/lib/sync/gitee')\n      \n      // 先获取用户信息，确保 giteeUsername 已设置\n      await getUserInfo();\n      \n      // 检查同步仓库状态\n      const giteeRepo = await getSyncRepoName('gitee')\n      const syncRepo = await checkSyncRepoState(giteeRepo)\n      if (syncRepo) {\n        setGiteeSyncRepoInfo(syncRepo)\n        setGiteeSyncRepoState(SyncStateEnum.success)\n      } else {\n        setGiteeSyncRepoInfo(undefined)\n        setGiteeSyncRepoState(SyncStateEnum.fail)\n      }\n    } catch (err) {\n      console.error('Failed to check Gitee repos:', err)\n      setGiteeSyncRepoState(SyncStateEnum.fail)\n    }\n  }\n\n  // 监听 token 变化，获取用户信息\n  useEffect(() => {\n    if (accessToken || giteeAccessToken || gitlabAccessToken || giteaAccessToken) {\n      handleGetUserInfo()\n    }\n  }, [accessToken, giteeAccessToken, gitlabAccessToken, giteaAccessToken, primaryBackupMethod])\n\n  return null\n}"
  },
  {
    "path": "src/components/audio-player.tsx",
    "content": "'use client'\n\nimport { useEffect, useRef, useState } from 'react'\nimport { Play, Pause } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Slider } from '@/components/ui/slider'\nimport { readFile, BaseDirectory } from '@tauri-apps/plugin-fs'\n\ninterface AudioPlayerProps {\n  audioPath: string\n  compact?: boolean\n}\n\nexport function AudioPlayer({ audioPath, compact = false }: AudioPlayerProps) {\n  const audioRef = useRef<HTMLAudioElement>(null)\n  \n  const [isPlaying, setIsPlaying] = useState(false)\n  const [currentTime, setCurrentTime] = useState(0)\n  const [duration, setDuration] = useState(0)\n  const [audioSrc, setAudioSrc] = useState<string>('')\n  const [isReady, setIsReady] = useState(false)\n\n  // 加载音频文件\n  useEffect(() => {\n    let blobUrl: string | null = null\n    \n    const loadAudio = async () => {\n      try {\n        // 读取音频文件\n        const fileData = await readFile(audioPath, { baseDir: BaseDirectory.AppData })\n        \n        // 根据文件扩展名确定 MIME 类型\n        const extension = audioPath.split('.').pop()?.toLowerCase()\n        const mimeType = extension === 'mp4' ? 'audio/mp4' :\n                        extension === 'webm' ? 'audio/webm' :\n                        extension === 'ogg' ? 'audio/ogg' :\n                        extension === 'wav' ? 'audio/wav' :\n                        extension === 'm4a' ? 'audio/mp4' :\n                        extension === 'mp3' ? 'audio/mpeg' :\n                        'audio/webm'\n        \n        // 创建 Blob URL\n        const buffer = fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength) as ArrayBuffer\n        const blob = new Blob([buffer], { type: mimeType })\n        blobUrl = URL.createObjectURL(blob)\n        \n        setAudioSrc(blobUrl)\n      } catch (error) {\n        console.error('加载音频失败:', error, '路径:', audioPath)\n      }\n    }\n    \n    loadAudio()\n    \n    // 清理函数\n    return () => {\n      if (blobUrl) {\n        URL.revokeObjectURL(blobUrl)\n      }\n    }\n  }, [audioPath])\n\n\n  // 播放/暂停\n  const togglePlay = async () => {\n    if (!audioRef.current || !isReady) return\n    \n    try {\n      if (isPlaying) {\n        audioRef.current.pause()\n      } else {\n        await audioRef.current.play()\n      }\n      setIsPlaying(!isPlaying)\n    } catch (error) {\n      console.error('播放失败:', error)\n      setIsPlaying(false)\n    }\n  }\n\n  // 进度调整\n  const handleSeek = (value: number[]) => {\n    if (!audioRef.current) return\n    const newTime = value[0]\n    audioRef.current.currentTime = newTime\n    setCurrentTime(newTime)\n  }\n\n  // 格式化时间\n  const formatTime = (time: number) => {\n    if (isNaN(time) || !isFinite(time)) {\n      return '0:00'\n    }\n    const minutes = Math.floor(time / 60)\n    const seconds = Math.floor(time % 60)\n    return `${minutes}:${seconds.toString().padStart(2, '0')}`\n  }\n\n  // 如果音频源未加载，显示加载提示\n  if (!audioSrc) {\n    if (compact) {\n      return (\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          disabled\n          className=\"size-5 shrink-0\"\n        >\n          <Play className=\"size-3\" />\n        </Button>\n      )\n    }\n\n    return (\n      <div className=\"w-full py-1 px-2 bg-muted/30 rounded text-center text-xs text-muted-foreground\">\n        加载音频中...\n      </div>\n    )\n  }\n\n  if (compact) {\n    return (\n      <div className=\"flex items-center\">\n        <audio\n          ref={audioRef}\n          src={audioSrc}\n          preload=\"metadata\"\n          onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}\n          onLoadedMetadata={(e) => {\n            const duration = e.currentTarget.duration\n            setDuration(duration)\n            setIsReady(true)\n          }}\n          onCanPlay={() => {\n            setIsReady(true)\n          }}\n          onEnded={() => setIsPlaying(false)}\n          onPlay={() => setIsPlaying(true)}\n          onPause={() => setIsPlaying(false)}\n          onError={(e) => {\n            console.error('音频加载错误:', e.currentTarget.error)\n            setIsReady(false)\n          }}\n        />\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={togglePlay}\n          disabled={!isReady}\n          className=\"size-5 shrink-0\"\n        >\n          {isPlaying ? (\n            <Pause className=\"size-3\" />\n          ) : (\n            <Play className=\"size-3\" />\n          )}\n        </Button>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"w-full flex items-center gap-1.5 py-1 pl-2 bg-muted/30 rounded\">\n      {/* 音频元素 */}\n      <audio\n        ref={audioRef}\n        src={audioSrc}\n        preload=\"metadata\"\n        onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}\n        onLoadedMetadata={(e) => {\n          const duration = e.currentTarget.duration\n          setDuration(duration)\n          setIsReady(true)\n        }}\n        onCanPlay={() => {\n          setIsReady(true)\n        }}\n        onEnded={() => setIsPlaying(false)}\n        onPlay={() => setIsPlaying(true)}\n        onPause={() => setIsPlaying(false)}\n        onError={(e) => {\n          console.error('音频加载错误:', e.currentTarget.error)\n          setIsReady(false)\n        }}\n        onLoadStart={() => {}}\n        onLoadedData={() => {}}\n      />\n\n      {/* 播放/暂停按钮 */}\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={togglePlay}\n        disabled={!isReady}\n        className=\"size-3 shrink-0\"\n      >\n        {isPlaying ? (\n          <Pause className=\"size-3\" />\n        ) : (\n          <Play className=\"size-3\" />\n        )}\n      </Button>\n\n      {/* 当前时间 */}\n      <span className=\"text-xs text-muted-foreground shrink-0 w-9 text-right\">\n        {formatTime(currentTime)}\n      </span>\n\n      {/* 进度条 */}\n      <Slider\n        value={[currentTime]}\n        max={duration || 100}\n        step={0.1}\n        onValueChange={handleSeek}\n        className=\"flex-1 cursor-pointer\"\n      />\n\n      {/* 总时长 */}\n      <span className=\"text-xs text-muted-foreground shrink-0 w-9\">\n        {formatTime(duration)}\n      </span>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/bottom-bar-icon-button.tsx",
    "content": "'use client'\n\nimport * as React from \"react\"\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\ntype BottomBarIconButtonProps = {\n  icon: React.ReactNode\n  label: string\n  onClick?: () => void\n  disabled?: boolean\n  active?: boolean\n  className?: string\n}\n\nexport function BottomBarIconButton({\n  icon,\n  label,\n  onClick,\n  disabled = false,\n  active = false,\n  className,\n}: BottomBarIconButtonProps) {\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <button\n            type=\"button\"\n            onClick={onClick}\n            disabled={disabled}\n            aria-label={label}\n            className={cn(\n              \"inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50\",\n              active && \"bg-accent text-foreground\",\n              className\n            )}\n          >\n            {icon}\n          </button>\n        </TooltipTrigger>\n        <TooltipContent side=\"top\">\n          <p>{label}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/console-filter.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\n\nexport function ConsoleFilter() {\n  useEffect(() => {\n    const originalError = console.error\n\n    console.error = (...args: any[]) => {\n      // 过滤 flushSync 警告\n      const message = args.join(' ')\n      if (message.includes('flushSync')) {\n        return\n      }\n      originalError.apply(console, args)\n    }\n\n    return () => {\n      console.error = originalError\n    }\n  }, [])\n\n  return null\n}\n"
  },
  {
    "path": "src/components/draggable-toolbar-item.tsx",
    "content": "import { useSortable } from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\nimport { ReactNode } from 'react'\n\ninterface DraggableToolbarItemProps {\n  id: string\n  children: ReactNode\n  shortcutNumber?: number\n  showShortcut?: boolean\n}\n\nexport function DraggableToolbarItem({ \n  id, \n  children, \n  shortcutNumber,\n  showShortcut = false \n}: DraggableToolbarItemProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({ id })\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    opacity: isDragging ? 0.5 : 1,\n  }\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      className=\"relative cursor-grab active:cursor-grabbing\"\n    >\n      {children}\n      {showShortcut && shortcutNumber !== undefined && (\n        <span className=\"absolute -bottom-1 left-1/2 -translate-x-1/2 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] font-medium flex items-center justify-center pointer-events-none z-10\">\n          {shortcutNumber}\n        </span>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/image-viewer.tsx",
    "content": "import { useState } from \"react\";\nimport { PhotoProvider, PhotoView } from \"react-photo-view\";\nimport { LocalImage } from \"./local-image\";\nimport { convertImage } from \"@/lib/utils\";\nimport { useEffect } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport function ImageViewer({url, path, imageClassName}: {url: string, path?: string, imageClassName?: string}) {\n  const [src, setSrc] = useState('')\n\n  async function init() {\n    const res = url.includes('http') ? url : await convertImage(`/${path}/${url}`)\n    setSrc(res)\n  }\n\n  useEffect(() => {\n    init()\n  }, [])\n\n  return (\n    <PhotoProvider>\n      <PhotoView src={src}>\n        <div>\n          <LocalImage\n            src={url.includes('http') ? url : `/${path}/${url}`}\n            alt=\"\"\n            className={cn(\"w-14 h-14 object-cover cursor-pointer\", imageClassName)}\n          />\n        </div>\n      </PhotoView>\n    </PhotoProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/local-image.tsx",
    "content": "'use client'\nimport Image from \"next/image\"\nimport React, { useState } from \"react\";\nimport { convertImage } from '@/lib/utils'\n\nexport function LocalImage({ onLoad, src, ...props }: React.ComponentProps<typeof Image>) {\n  const [localSrc, setLocalSrc] = useState<string>('')\n\n  async function getAppDataDir() {\n    if (src.toString().includes('http')) {\n      setLocalSrc(src.toString())\n    } else {\n      const covertFileSrcPath = await convertImage(src as string)\n      setLocalSrc(covertFileSrcPath)\n    }\n  }\n\n  React.useEffect(() => {\n    getAppDataDir()\n  }, [src])\n\n  // 如果 loaclSrc 存在\n  return (\n    localSrc ?\n    <Image onLoad={onLoad} src={localSrc} alt=\"\" width={0} height={0} className={props.className} style={props.style} /> :\n    null\n  )\n}\n"
  },
  {
    "path": "src/components/memories/memory-form.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'\nimport { Alert, AlertDescription } from '@/components/ui/alert'\nimport useMemoriesStore from '@/stores/memories'\nimport { toast } from '@/hooks/use-toast'\n\ninterface MemoryFormProps {\n  onSuccess?: () => void\n}\n\nexport function MemoryForm({ onSuccess }: MemoryFormProps) {\n  const t = useTranslations('settings.memories')\n  const [content, setContent] = useState('')\n  const [category, setCategory] = useState<'preference' | 'memory'>('preference')\n  const [submitting, setSubmitting] = useState(false)\n  const { addMemory } = useMemoriesStore()\n\n  const handleSubmit = async () => {\n    if (!content.trim()) {\n      toast({\n        title: t('error'),\n        description: t('errorEmpty'),\n        variant: 'destructive',\n      })\n      return\n    }\n\n    setSubmitting(true)\n    try {\n      await addMemory(content, category)\n      setContent('')\n      toast({\n        title: t('success'),\n        description: t('saved'),\n      })\n      onSuccess?.()\n    } catch (error) {\n      toast({\n        title: t('error'),\n        description: t('errorSave') + `: ${error}`,\n        variant: 'destructive',\n      })\n    } finally {\n      setSubmitting(false)\n    }\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <Alert>\n        <AlertDescription className=\"space-y-2\">\n          <p className=\"font-medium\">{t('form.categoryDescription')}</p>\n          <p className=\"text-sm text-muted-foreground pl-3\">• {t('form.preferenceDescription')}</p>\n          <p className=\"text-sm text-muted-foreground pl-3\">• {t('form.memoryDescription')}</p>\n        </AlertDescription>\n      </Alert>\n\n      <div>\n        <Label htmlFor=\"memory-content\">{t('form.contentLabel')}</Label>\n        <Textarea\n          id=\"memory-content\"\n          placeholder={t('form.contentPlaceholder')}\n          value={content}\n          onChange={(e) => setContent(e.target.value)}\n          rows={3}\n        />\n      </div>\n\n      <div>\n        <Label>{t('form.categoryLabel')}</Label>\n        <RadioGroup value={category} onValueChange={(v) => setCategory(v as 'preference' | 'memory')}>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"preference\" id=\"preference\" />\n            <Label htmlFor=\"preference\">{t('form.preferenceLabel')}</Label>\n          </div>\n          <div className=\"flex items-center space-x-2\">\n            <RadioGroupItem value=\"memory\" id=\"memory\" />\n            <Label htmlFor=\"memory\">{t('form.memoryLabel')}</Label>\n          </div>\n        </RadioGroup>\n      </div>\n\n      <Button onClick={handleSubmit} disabled={submitting || !content.trim()}>\n        {submitting ? t('form.saving') : t('form.save')}\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/memories/memory-item.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { Memory } from '@/db/memories'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { X } from 'lucide-react'\n\ninterface MemoryItemProps {\n  memory: Memory\n  onDelete: () => void\n}\n\nexport function MemoryItem({ memory, onDelete }: MemoryItemProps) {\n  const t = useTranslations('settings.memories')\n\n  const categoryLabel = memory.category === 'preference' ? t('preference') : t('memory')\n\n  return (\n    <div className=\"group flex items-center gap-3 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors\">\n      <Badge className=\"shrink-0 mt-0.5\">\n        {categoryLabel}\n      </Badge>\n      <p className=\"flex-1 text-sm leading-relaxed line-clamp-1\">{memory.content}</p>\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"shrink-0 opacity-0 group-hover:opacity-100 transition-opacity\"\n        onClick={onDelete}\n      >\n        <X className=\"size-3.5\" />\n      </Button>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/memories/memory-list.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { useTranslations } from 'next-intl'\nimport { MemoryItem } from './memory-item'\nimport { MemoryForm } from './memory-form'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Button } from '@/components/ui/button'\nimport { Plus } from 'lucide-react'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog'\nimport useMemoriesStore from '@/stores/memories'\nimport { Skeleton } from '@/components/ui/skeleton'\n\ntype TabValue = 'all' | 'preference' | 'memory'\n\nexport function MemoryList() {\n  const t = useTranslations('settings.memories')\n  const { memories, loading, deleteMemory, loadMemories } = useMemoriesStore()\n  const [activeTab, setActiveTab] = useState<TabValue>('all')\n  const [open, setOpen] = useState(false)\n\n  useEffect(() => {\n    loadMemories()\n  }, [loadMemories])\n\n  const preferences = memories.filter(m => m.category === 'preference')\n  const memoryList = memories.filter(m => m.category === 'memory')\n\n  if (loading) {\n    return (\n      <div className=\"space-y-1\">\n        <Skeleton className=\"h-10 w-full\" />\n        <Skeleton className=\"h-10 w-full\" />\n        <Skeleton className=\"h-10 w-full\" />\n      </div>\n    )\n  }\n\n  if (memories.length === 0) {\n    return (\n      <div className=\"text-center py-12\">\n        <p className=\"text-muted-foreground mb-2\">{t('empty')}</p>\n        <p className=\"text-sm text-muted-foreground/70\">{t('emptyHint')}</p>\n      </div>\n    )\n  }\n\n  return (\n    <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>\n      <div className=\"flex items-center justify-between gap-4\">\n        <TabsList>\n          <TabsTrigger value=\"all\">\n            {t('tabs.all')} ({memories.length})\n          </TabsTrigger>\n          <TabsTrigger value=\"preference\">\n            {t('tabs.preference')} ({preferences.length})\n          </TabsTrigger>\n          <TabsTrigger value=\"memory\">\n            {t('tabs.memory')} ({memoryList.length})\n          </TabsTrigger>\n        </TabsList>\n\n        <Dialog open={open} onOpenChange={setOpen}>\n          <DialogTrigger asChild>\n            <Button variant=\"default\" size=\"sm\">\n              <Plus className=\"size-4 mr-2\" />\n              {t('addMemory')}\n            </Button>\n          </DialogTrigger>\n          <DialogContent>\n            <DialogHeader>\n              <DialogTitle>{t('form.title')}</DialogTitle>\n              <DialogDescription>{t('form.contentPlaceholder')}</DialogDescription>\n            </DialogHeader>\n            <MemoryForm onSuccess={() => setOpen(false)} />\n          </DialogContent>\n        </Dialog>\n      </div>\n\n      <TabsContent value=\"all\" className=\"mt-4\">\n        <div className=\"space-y-0.5\">\n          {memories.map(memory => (\n            <MemoryItem\n              key={memory.id}\n              memory={memory}\n              onDelete={() => deleteMemory(memory.id)}\n            />\n          ))}\n        </div>\n      </TabsContent>\n\n      <TabsContent value=\"preference\" className=\"mt-4\">\n        <div className=\"space-y-0.5\">\n          {preferences.map(memory => (\n            <MemoryItem\n              key={memory.id}\n              memory={memory}\n              onDelete={() => deleteMemory(memory.id)}\n            />\n          ))}\n        </div>\n      </TabsContent>\n\n      <TabsContent value=\"memory\" className=\"mt-4\">\n        <div className=\"space-y-0.5\">\n          {memoryList.map(memory => (\n            <MemoryItem\n              key={memory.id}\n              memory={memory}\n              onDelete={() => deleteMemory(memory.id)}\n            />\n          ))}\n        </div>\n      </TabsContent>\n    </Tabs>\n  )\n}\n"
  },
  {
    "path": "src/components/memories/memory-stats.tsx",
    "content": "'use client'\n\nimport { useTranslations } from 'next-intl'\nimport { useEffect } from 'react'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport useMemoriesStore from '@/stores/memories'\n\nexport function MemoryStats() {\n  const t = useTranslations('settings.memories')\n  const { stats, loadStats, loading } = useMemoriesStore()\n\n  useEffect(() => {\n    loadStats()\n  }, [loadStats])\n\n  if (loading || !stats) {\n    return null\n  }\n\n  return (\n    <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n      <Card>\n        <CardHeader className=\"pb-2\">\n          <CardTitle className=\"text-sm font-medium\">{t('stats.total')}</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"text-2xl font-bold\">{stats.total}</div>\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader className=\"pb-2\">\n          <CardTitle className=\"text-sm font-medium\">{t('stats.preferences')}</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"text-2xl font-bold\">{stats.preferences}</div>\n        </CardContent>\n      </Card>\n\n      <Card>\n        <CardHeader className=\"pb-2\">\n          <CardTitle className=\"text-sm font-medium\">{t('stats.memories')}</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"text-2xl font-bold\">{stats.memories}</div>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/mobile-record-tools.tsx",
    "content": "'use client'\n\nimport { SimpleMobileTool } from '@/components/simple-mobile-tool'\nimport emitter from '@/lib/emitter'\nimport { exists, writeTextFile } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions } from '@/lib/workspace'\nimport useArticleStore from '@/stores/article'\nimport { useRouter } from 'next/navigation'\nimport { toast } from '@/hooks/use-toast'\nimport { useTranslations } from 'next-intl'\n\ninterface MobileRecordToolsProps {\n  onClose?: () => void\n}\n\nexport function MobileRecordTools({ onClose }: MobileRecordToolsProps) {\n  const router = useRouter()\n  const t = useTranslations()\n  const { loadFileTree, setActiveFilePath } = useArticleStore()\n\n  // 移动端固定的记录工具（排除截图）\n  const mobileTools = [\n    { id: 'write' },\n    { id: 'text' },\n    { id: 'recording' },\n    { id: 'image' },\n    { id: 'link' },\n    { id: 'file' }\n  ]\n\n  const createQuickWriteFile = async () => {\n    let index = 0\n    let fileName = 'untitled.md'\n\n    while (true) {\n      const pathOptions = await getFilePathOptions(fileName)\n      const fileExists = pathOptions.baseDir\n        ? await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        : await exists(pathOptions.path)\n      if (!fileExists) break\n\n      index += 1\n      fileName = `untitled-${index}.md`\n    }\n\n    const pathOptions = await getFilePathOptions(fileName)\n    if (pathOptions.baseDir) {\n      await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n    } else {\n      await writeTextFile(pathOptions.path, '')\n    }\n\n    return fileName\n  }\n\n  const handleQuickWrite = async () => {\n    try {\n      const fileName = await createQuickWriteFile()\n      await loadFileTree()\n      await setActiveFilePath(fileName)\n      router.push('/mobile/writing')\n      onClose?.()\n    } catch {\n      toast({\n        title: t('common.error'),\n        description: t('common.error'),\n        variant: 'destructive',\n      })\n    }\n  }\n\n  const handleToolClick = async (toolId: string) => {\n    if (toolId === 'write') {\n      await handleQuickWrite()\n      return\n    }\n\n    // 发射工具快捷键事件\n    emitter.emit(`toolbar-shortcut-${toolId}` as any)\n    // 点击后关闭弹窗\n    if (onClose) {\n      onClose()\n    }\n  }\n\n  // 暂时忽略 onClose 参数的 lint 警告，未来可能用于在操作成功后关闭抽屉\n  void onClose\n\n  return (\n    <div className=\"grid w-full grid-cols-3 gap-1\">\n      {mobileTools.map((tool) => (\n        <SimpleMobileTool \n          key={tool.id}\n          toolId={tool.id}\n          onToolClick={handleToolClick}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/mobile-statusbar.tsx",
    "content": "'use client'\n\nimport { useTheme } from \"next-themes\"\nimport { useEffect } from \"react\"\n\nexport function MobileStatusBar() {\n  const { theme, systemTheme } = useTheme()\n\n  useEffect(() => {\n    const currentTheme = theme === 'system' ? systemTheme : theme\n    const isDark = currentTheme === 'dark'\n\n    const updateStatusBarColor = () => {\n      const statusBarColor = isDark ? '#0a0a0a' : '#ffffff'\n      \n      let metaThemeColor = document.querySelector('meta[name=\"theme-color\"]')\n      if (!metaThemeColor) {\n        metaThemeColor = document.createElement('meta')\n        metaThemeColor.setAttribute('name', 'theme-color')\n        document.head.appendChild(metaThemeColor)\n      }\n      metaThemeColor.setAttribute('content', statusBarColor)\n\n      let metaStatusBar = document.querySelector('meta[name=\"mobile-web-app-status-bar-style\"]')\n      if (!metaStatusBar) {\n        metaStatusBar = document.createElement('meta')\n        metaStatusBar.setAttribute('name', 'mobile-web-app-status-bar-style')\n        document.head.appendChild(metaStatusBar)\n      }\n      metaStatusBar.setAttribute('content', isDark ? 'black-translucent' : 'default')\n    }\n\n    const timer = setTimeout(updateStatusBarColor, 100)\n\n    return () => clearTimeout(timer)\n  }, [theme, systemTheme])\n\n  return null\n}\n"
  },
  {
    "path": "src/components/onboarding-spotlight-position.ts",
    "content": "export interface SpotlightRect {\n  top: number\n  left: number\n  width: number\n  height: number\n}\n\nexport function getSpotlightTooltipPosition({\n  rect,\n  viewportWidth,\n  viewportHeight,\n  tooltipWidth,\n  tooltipHeight,\n}: {\n  rect: SpotlightRect\n  viewportWidth: number\n  viewportHeight: number\n  tooltipWidth: number\n  tooltipHeight: number\n}) {\n  const left = Math.min(\n    Math.max(16, rect.left + rect.width / 2 - tooltipWidth / 2),\n    viewportWidth - tooltipWidth - 16\n  )\n\n  const preferredTop = rect.top - tooltipHeight - 16\n  const fallbackTop = rect.top + rect.height + 16\n  const top = preferredTop >= 16\n    ? preferredTop\n    : Math.min(fallbackTop, viewportHeight - tooltipHeight - 16)\n\n  return { top, left }\n}\n"
  },
  {
    "path": "src/components/onboarding-spotlight.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { getSpotlightTooltipPosition, type SpotlightRect } from './onboarding-spotlight-position'\n\ninterface OnboardingSpotlightProps {\n  targetId: string | null\n  title: string\n  description: string\n  onDismiss: () => void\n}\n\nfunction measureTarget(targetId: string): SpotlightRect | null {\n  const element = document.getElementById(targetId)\n  if (!element) {\n    return null\n  }\n\n  const rect = element.getBoundingClientRect()\n  return {\n    top: rect.top,\n    left: rect.left,\n    width: rect.width,\n    height: rect.height,\n  }\n}\n\nexport function OnboardingSpotlight({\n  targetId,\n  title,\n  description,\n  onDismiss,\n}: OnboardingSpotlightProps) {\n  const [rect, setRect] = useState<SpotlightRect | null>(null)\n\n  useEffect(() => {\n    if (!targetId) {\n      setRect(null)\n      return\n    }\n\n    const update = () => {\n      setRect(measureTarget(targetId))\n    }\n\n    update()\n    window.addEventListener('resize', update)\n    window.addEventListener('scroll', update, true)\n    const intervalId = window.setInterval(update, 250)\n\n    return () => {\n      window.removeEventListener('resize', update)\n      window.removeEventListener('scroll', update, true)\n      window.clearInterval(intervalId)\n    }\n  }, [targetId])\n\n  if (!rect) {\n    return null\n  }\n\n  const tooltipWidth = 280\n  const tooltipHeight = 120\n  const { top: tooltipTop, left: tooltipLeft } = getSpotlightTooltipPosition({\n    rect,\n    viewportWidth: window.innerWidth,\n    viewportHeight: window.innerHeight,\n    tooltipWidth,\n    tooltipHeight,\n  })\n  const holeTop = Math.max(0, rect.top - 8)\n  const holeLeft = Math.max(0, rect.left - 8)\n  const holeWidth = rect.width + 16\n  const holeHeight = rect.height + 16\n\n  return (\n    <div className=\"fixed inset-0 z-[10010]\" onClick={onDismiss}>\n      <div\n        className=\"pointer-events-none absolute rounded-2xl border-2 border-primary transition-all duration-200\"\n        style={{\n          top: holeTop,\n          left: holeLeft,\n          width: holeWidth,\n          height: holeHeight,\n          boxShadow: '0 0 0 9999px rgba(15, 23, 42, 0.45)',\n        }}\n      />\n      <div\n        className=\"absolute rounded-xl border bg-background/95 p-3 shadow-xl backdrop-blur\"\n        style={{\n          top: tooltipTop,\n          left: tooltipLeft,\n          width: tooltipWidth,\n        }}\n        onClick={(event) => event.stopPropagation()}\n      >\n        <p className=\"text-sm font-medium\">{title}</p>\n        <p className=\"mt-1 text-xs text-muted-foreground\">{description}</p>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/components/open-broswer.tsx",
    "content": "import { open } from '@tauri-apps/plugin-shell';\nimport Link from 'next/link';\nimport { Button } from '@/components/ui/button';\n\nexport const OpenBroswer = ({ type = 'link', title, url, className }: { type?: 'link' | 'button', title: string, url: string, className?: string }) => {\n  return (\n    type === 'button' ?\n    <Button onClick={() => {open(url)}}>{title}</Button> :\n    <Link \n      className={`underline hover:text-zinc-900 ${className}`}\n      href={'#'}\n      onClick={() => {open(url)}}\n    >{title}</Link>\n  );\n};"
  },
  {
    "path": "src/components/pin-toggle.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Pin, PinOff } from \"lucide-react\"\nimport { useTranslations } from 'next-intl'\n\nimport { Button } from \"@/components/ui/button\"\nimport { getCurrentWindow } from '@tauri-apps/api/window';\nimport { useState, useEffect } from \"react\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\nexport function PinToggle() {\n  const t = useTranslations();\n  const [isPin, setIsPin] = useState(false)\n\n  useEffect(() => {\n    async function loadPinState() {\n      const store = await Store.load('store.json')\n      const pin = await store.get<boolean>('pin')\n      setIsPin(!!pin)\n    }\n    loadPinState()\n  }, [])\n\n  async function togglePin() {\n    const store = await Store.load('store.json')\n    const newPinState = !isPin\n    setIsPin(newPinState)\n    const window = getCurrentWindow()\n    await window.setAlwaysOnTop(newPinState)\n    await store.set('pin', newPinState)\n  }\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      className=\"h-8 w-8\"\n      onClick={togglePin}\n      title={isPin ? t('common.unpin') : t('common.pin')}\n    >\n      {isPin ? <Pin className=\"h-4 w-4\" /> : <PinOff className=\"h-4 w-4\" />}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "src/components/providers/NextIntlProvider.tsx",
    "content": "import { NextIntlClientProvider } from 'next-intl';\nimport { useEffect, useState } from 'react';\n\n// 加载语言文件\nasync function loadMessages(locale: string) {\n  try {\n    return (await import(`../../../messages/${locale}.json`)).default;\n  } catch (error) {\n    console.error(`Failed to load messages for locale: ${locale}`, error);\n    // 如果加载失败，返回中文作为后备\n    return (await import(`../../../messages/zh.json`)).default;\n  }\n}\n\n// 加载中文消息作为回退\nasync function loadFallbackMessages() {\n  return (await import(`../../../messages/zh.json`)).default;\n}\n\n// 深度合并对象，用中文填充缺失的翻译\nfunction deepMerge(target: any, source: any): any {\n  const result = { ...target };\n  \n  for (const key in source) {\n    if (source.hasOwnProperty(key)) {\n      if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {\n        // 如果是对象，递归合并\n        result[key] = deepMerge(target[key] || {}, source[key]);\n      } else if (!(key in target)) {\n        // 如果目标中不存在该键，使用源（中文）的值\n        result[key] = source[key];\n      }\n    }\n  }\n  \n  return result;\n}\n\nexport function NextIntlProvider({ children }: { children: React.ReactNode }) {\n  const [messages, setMessages] = useState<any>(null);\n  const [locale, setLocale] = useState<string>('zh');\n\n  useEffect(() => {\n    // 从 localStorage 获取语言设置\n    const savedLocale = localStorage.getItem('app-language') || 'zh';\n    setLocale(savedLocale);\n    \n    // 加载对应的语言文件和中文回退\n    Promise.all([\n      loadMessages(savedLocale),\n      loadFallbackMessages()\n    ]).then(([currentMessages, fallbackMessages]) => {\n      // 如果是中文，直接使用\n      if (savedLocale === 'zh') {\n        setMessages(currentMessages);\n      } else {\n        // 其他语言，用中文填充缺失的翻译\n        const mergedMessages = deepMerge(currentMessages, fallbackMessages);\n        setMessages(mergedMessages);\n      }\n    });\n  }, []);\n\n  // 等待消息加载完成\n  if (!messages) {\n    return null;\n  }\n\n  return (\n    <NextIntlClientProvider locale={locale} messages={messages}>\n      {children}\n    </NextIntlClientProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/recording-dialog.tsx",
    "content": "'use client';\n\nimport { useState } from 'react';\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog';\nimport { Button } from '@/components/ui/button';\nimport { Mic, Square, Play, Pause, Loader2 } from 'lucide-react';\nimport useRecordingStore from '@/stores/recording';\nimport { NO_TRANSCRIPTION_MESSAGE, transcribeRecording } from '@/lib/audio';\nimport { useTranslations } from 'next-intl';\nimport { toast } from '@/hooks/use-toast';\n\ninterface RecordingDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onTranscriptionComplete?: (text: string) => void;\n}\n\nexport function RecordingDialog({ open, onOpenChange, onTranscriptionComplete }: RecordingDialogProps) {\n  const t = useTranslations('recording');\n  const {\n    isRecording,\n    isPaused,\n    recordingDuration,\n    startRecording,\n    pauseRecording,\n    resumeRecording,\n    stopRecording,\n    cancelRecording,\n  } = useRecordingStore();\n\n  const [isProcessing, setIsProcessing] = useState(false);\n\n  // 格式化录音时长\n  const formatDuration = (seconds: number) => {\n    const mins = Math.floor(seconds / 60);\n    const secs = seconds % 60;\n    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;\n  };\n\n  // 开始录音\n  const handleStart = async () => {\n    try {\n      await startRecording();\n    } catch (error) {\n      cancelRecording();\n      toast({\n        title: t('error'),\n        description: error instanceof Error ? error.message : t('startError'),\n        variant: 'destructive',\n      });\n    }\n  };\n\n  // 暂停/继续录音\n  const handlePauseResume = () => {\n    if (isPaused) {\n      resumeRecording();\n    } else {\n      pauseRecording();\n    }\n  };\n\n  // 停止录音并识别\n  const handleStop = async () => {\n    try {\n      setIsProcessing(true);\n      const audioBlob = await stopRecording();\n      \n      if (!audioBlob) {\n        toast({\n          title: t('error'),\n          description: t('noAudioData'),\n          variant: 'destructive',\n        });\n        return;\n      }\n\n      const transcription = await transcribeRecording(audioBlob);\n      \n      if (transcription) {\n        toast({\n          title: t('success'),\n          description: t('transcriptionSuccess'),\n        });\n        onTranscriptionComplete?.(transcription);\n        onOpenChange(false);\n      } else {\n        toast({\n          title: t('error'),\n          description: NO_TRANSCRIPTION_MESSAGE,\n          variant: 'destructive',\n        });\n      }\n    } catch (error) {\n      console.error('语音识别失败:', error);\n      toast({\n        title: t('error'),\n        description: error instanceof Error ? error.message : t('transcriptionError'),\n        variant: 'destructive',\n      });\n    } finally {\n      setIsProcessing(false);\n    }\n  };\n\n  // 取消录音\n  const handleCancel = () => {\n    cancelRecording();\n    onOpenChange(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>{t('title')}</DialogTitle>\n          <DialogDescription>{t('description')}</DialogDescription>\n        </DialogHeader>\n\n        <div className=\"flex flex-col items-center gap-6 py-8\">\n          {/* 录音时长显示 */}\n          <div className=\"text-5xl font-mono font-bold text-foreground\">\n            {formatDuration(recordingDuration)}\n          </div>\n\n          {/* 录音状态指示器 */}\n          <div className=\"flex items-center gap-3\">\n            {isRecording && !isPaused && (\n              <>\n                <div className=\"size-3 rounded-full bg-red-500 animate-pulse\" />\n                <span className=\"text-sm text-muted-foreground\">{t('recording')}</span>\n              </>\n            )}\n            {isPaused && (\n              <>\n                <div className=\"size-3 rounded-full bg-yellow-500\" />\n                <span className=\"text-sm text-muted-foreground\">{t('paused')}</span>\n              </>\n            )}\n            {!isRecording && !isProcessing && (\n              <span className=\"text-sm text-muted-foreground\">{t('ready')}</span>\n            )}\n            {isProcessing && (\n              <>\n                <Loader2 className=\"size-4 animate-spin\" />\n                <span className=\"text-sm text-muted-foreground\">{t('processing')}</span>\n              </>\n            )}\n          </div>\n\n          {/* 控制按钮 */}\n          <div className=\"flex gap-3\">\n            {!isRecording && !isProcessing && (\n              <Button\n                size=\"lg\"\n                onClick={handleStart}\n                className=\"size-16 rounded-full\"\n              >\n                <Mic className=\"size-6\" />\n              </Button>\n            )}\n\n            {isRecording && (\n              <>\n                <Button\n                  size=\"lg\"\n                  variant=\"outline\"\n                  onClick={handlePauseResume}\n                  className=\"size-16 rounded-full\"\n                >\n                  {isPaused ? <Play className=\"size-6\" /> : <Pause className=\"size-6\" />}\n                </Button>\n\n                <Button\n                  size=\"lg\"\n                  variant=\"destructive\"\n                  onClick={handleStop}\n                  className=\"size-16 rounded-full\"\n                  disabled={isProcessing}\n                >\n                  <Square className=\"size-6\" />\n                </Button>\n              </>\n            )}\n          </div>\n\n          {/* 取消按钮 */}\n          {(isRecording || isProcessing) && (\n            <Button\n              variant=\"ghost\"\n              onClick={handleCancel}\n              disabled={isProcessing}\n            >\n              {t('cancel')}\n            </Button>\n          )}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src/components/recording-indicator.tsx",
    "content": "'use client';\n\nimport { Mic } from 'lucide-react';\nimport { SidebarMenuButton } from '@/components/ui/sidebar';\nimport useRecordingStore from '@/stores/recording';\nimport { useTranslations } from 'next-intl';\nimport { useState } from 'react';\nimport { RecordingDialog } from './recording-dialog';\n\nexport function RecordingIndicator() {\n  const t = useTranslations('recording');\n  const { isRecording, recordingDuration } = useRecordingStore();\n  const [dialogOpen, setDialogOpen] = useState(false);\n\n  // 格式化录音时长\n  const formatDuration = (seconds: number) => {\n    const mins = Math.floor(seconds / 60);\n    const secs = seconds % 60;\n    return `${mins}:${secs.toString().padStart(2, '0')}`;\n  };\n\n  // 如果没有在录音，不显示指示器\n  if (!isRecording) {\n    return null;\n  }\n\n  // 点击打开录音对话框\n  const handleClick = () => {\n    setDialogOpen(true);\n  };\n\n  return (\n    <>\n      <SidebarMenuButton\n        onClick={handleClick}\n        className=\"md:h-8 md:p-0 relative animate-pulse\"\n        tooltip={{\n          children: `${t('recording')} - ${formatDuration(recordingDuration)}`,\n          hidden: false,\n        }}\n      >\n        <div className=\"flex size-8 items-center justify-center rounded-lg\">\n          <div className=\"relative\">\n            <Mic className=\"size-4 text-red-500\" />\n            <div className=\"absolute -top-1 -right-1 size-2 rounded-full bg-red-500 animate-pulse\" />\n          </div>\n        </div>\n      </SidebarMenuButton>\n\n      <RecordingDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/search-dialog.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useMemo, useRef } from 'react'\nimport { debounce } from 'lodash-es'\nimport {\n  Command,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command'\nimport { Drawer, DrawerContent } from '@/components/ui/drawer'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from '@/components/ui/empty'\nimport { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'\nimport { Separator } from '@/components/ui/separator'\nimport { cn } from '@/lib/utils'\nimport { File, FolderTree, NotebookPen, SearchX, Tags } from 'lucide-react'\nimport { useTranslations } from 'next-intl'\nimport { Store } from '@tauri-apps/plugin-store'\nimport useArticleStore from '@/stores/article'\nimport useMarkStore from '@/stores/mark'\nimport useTagStore from '@/stores/tag'\nimport { useSidebarStore } from '@/stores/sidebar'\nimport { usePathname, useRouter } from 'next/navigation'\nimport emitter from '@/lib/emitter'\nimport { EmitterRecordEvents } from '@/config/emitters'\nimport { search, type SearchableItem } from '@/lib/search-utils'\n\ninterface SearchDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\ntype SearchFilter = 'all' | 'record' | 'article'\n\ninterface EnhancedSearchResult {\n  id: string\n  markId?: number\n  path?: string\n  article?: string\n  content?: string\n  desc?: string\n  title: string\n  searchType: 'article' | 'record'\n  tagId?: number\n  tagName?: string\n  type?: string\n  url?: string\n  highlightText: string\n  score: number\n  firstMatchIndex?: number\n}\n\nexport function SearchDialog({ open, onOpenChange }: SearchDialogProps) {\n  const t = useTranslations()\n  const router = useRouter()\n  const pathname = usePathname()\n  const [searchValue, setSearchValue] = useState('')\n  const [searchResult, setSearchResult] = useState<EnhancedSearchResult[]>([])\n  const [searchFilter, setSearchFilter] = useState<SearchFilter>('all')\n  const { allArticle, loadAllArticle, setActiveFilePath, setMatchPosition, setPendingSearchKeyword, setCollapsibleList } = useArticleStore()\n  const { allMarks, fetchAllMarks, setPendingScrollMarkId } = useMarkStore()\n  const { tags, fetchTags, setCurrentTagId } = useTagStore()\n  const { setLeftSidebarTab } = useSidebarStore()\n  const isMobileRoute = pathname.startsWith('/mobile')\n  const searchInputRef = useRef<HTMLInputElement | null>(null)\n\n  function extractTitleFromPath(path: string): string {\n    if (!path) return ''\n    const parts = path.split(/[\\/\\\\]/)\n    const fileName = parts[parts.length - 1]\n    return fileName.includes('.') ? fileName.substring(0, fileName.lastIndexOf('.')) : fileName\n  }\n\n  // 高亮搜索关键词\n  function highlightText(text: string, query: string) {\n    if (!query.trim() || !text) return text\n    \n    const parts: React.ReactNode[] = []\n    const lowerText = text.toLowerCase()\n    const lowerQuery = query.toLowerCase().trim()\n    \n    let lastIndex = 0\n    let index = lowerText.indexOf(lowerQuery)\n    \n    while (index !== -1) {\n      // 添加匹配前的文本\n      if (index > lastIndex) {\n        parts.push(text.substring(lastIndex, index))\n      }\n      \n      // 添加高亮的匹配文本\n      parts.push(\n        <mark key={index} className=\"bg-yellow-200 dark:bg-yellow-800 text-foreground px-0.5 rounded\">\n          {text.substring(index, index + lowerQuery.length)}\n        </mark>\n      )\n      \n      lastIndex = index + lowerQuery.length\n      index = lowerText.indexOf(lowerQuery, lastIndex)\n    }\n    \n    // 添加剩余文本\n    if (lastIndex < text.length) {\n      parts.push(text.substring(lastIndex))\n    }\n    \n    return <>{parts}</>\n  }\n\n  function getResultMeta(item: EnhancedSearchResult) {\n    if (item.searchType === 'record') {\n      return {\n        icon: Tags,\n        primary: item.tagName || t('search.item.record'),\n        secondary: item.type || null,\n      }\n    }\n\n    return {\n      icon: FolderTree,\n      primary: item.path || t('search.item.article'),\n      secondary: null,\n    }\n  }\n\n  function getResultTone(item: EnhancedSearchResult) {\n    return item.searchType === 'record'\n      ? 'bg-cyan-500/10 text-cyan-700 dark:text-cyan-300 border-cyan-500/20'\n      : 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/20'\n  }\n\n  const performSearch = useCallback((value: string) => {\n    if (!value.trim()) {\n      setSearchResult([])\n      return\n    }\n    \n    // 构建文章搜索项\n    const articleItems: SearchableItem[] = allArticle.map((item, index) => ({\n      id: `article-${index}-${item.path?.replace(/[^a-zA-Z0-9]/g, '-')}`,\n      title: extractTitleFromPath(item.path || ''),\n      content: item.article || '',\n      metadata: {\n        path: item.path,\n        article: item.article,\n        searchType: 'article'\n      }\n    }))\n    \n    // 准备记录搜索数据\n    const markItems: SearchableItem[] = allMarks.map((item, index) => {\n      const tag = tags.find(tag => tag.id === item.tagId)\n      return {\n        id: `mark-${index}-${item.id}`,\n        title: item.desc || item.content?.slice(0, 50) || '',\n        content: `${item.content || ''} ${item.desc || ''} ${tag?.name || ''}`,\n        metadata: {\n          markId: item.id,\n          content: item.content,\n          desc: item.desc,\n          tagName: tag?.name,\n          tagId: item.tagId,\n          type: item.type,\n          url: item.url,\n          searchType: 'record'\n        }\n      }\n    })\n    \n    // 合并所有搜索项\n    const allItems = [...articleItems, ...markItems]\n    \n    // 执行搜索（自动合并精确和模糊结果）\n    const searchResults = search(allItems, value, { \n      maxResults: 50 \n    })\n    \n    // 转换为组件需要的格式\n    const results: EnhancedSearchResult[] = searchResults.map(result => {\n      const metadata = result.item.metadata || {}\n      const firstMatch = result.matches[0]\n      \n      return {\n        id: result.item.id,\n        title: result.item.title,\n        searchType: metadata.searchType as 'article' | 'record',\n        highlightText: result.highlightText,\n        score: result.score,\n        firstMatchIndex: firstMatch?.index,\n        // 文章特定字段\n        path: metadata.path,\n        article: metadata.article,\n        // 记录特定字段\n        markId: metadata.markId,\n        content: metadata.content,\n        desc: metadata.desc,\n        tagName: metadata.tagName,\n        tagId: metadata.tagId,\n        type: metadata.type,\n        url: metadata.url\n      }\n    })\n    \n    setSearchResult(results)\n  }, [allArticle, allMarks, tags])\n\n  // 防抖搜索，300ms 延迟\n  const debouncedSearch = useMemo(\n    () => debounce(performSearch, 300),\n    [performSearch]\n  )\n\n  const filteredSearchResult = useMemo(() => {\n    if (searchFilter === 'all') {\n      return searchResult\n    }\n    return searchResult.filter((item) => item.searchType === searchFilter)\n  }, [searchFilter, searchResult])\n\n  async function handleSelect(item: EnhancedSearchResult) {\n    // 如果是记录类型，跳转到记录页面并设置对应的 tag\n    if (item.searchType === 'record') {\n      onOpenChange(false)\n      setPendingSearchKeyword('')\n      setMatchPosition(null)\n      setPendingScrollMarkId(item.markId ?? null)\n\n      if (item.tagId) {\n        await setCurrentTagId(item.tagId)\n      }\n\n      if (!isMobileRoute) {\n        // PC 端：切换到记录标签页\n        await setLeftSidebarTab('notes')\n      } else {\n        // 移动端：进入记录页\n        router.push('/mobile/record')\n      }\n\n      emitter.emit(EmitterRecordEvents.refreshMarks)\n\n      return\n    }\n    \n    onOpenChange(false)\n    setPendingScrollMarkId(null)\n\n    // PC 端切换到笔记标签页；移动端直接跳转写作页\n    if (!isMobileRoute) {\n      await setLeftSidebarTab('files')\n    }\n    \n    // 如果是文章类型，跳转到文章页面\n    if (item.firstMatchIndex !== undefined) {\n      setMatchPosition(item.firstMatchIndex)\n    }\n    setPendingSearchKeyword(searchValue.trim())\n    \n    const filePath = item.path as string\n    \n    const setupAndNavigate = async () => {\n      // 展开文件夹路径\n      const pathParts = filePath.split('/')\n      pathParts.pop()\n      \n      let currentPath = ''\n      for (const part of pathParts) {\n        if (currentPath) {\n          currentPath += '/' + part\n        } else {\n          currentPath = part\n        }\n        \n        if (currentPath) {\n          await setCollapsibleList(currentPath, true)\n        }\n      }\n      \n      // 设置活动文件路径\n      await setActiveFilePath(filePath)\n      \n      // 读取文件内容\n      const { readArticle } = useArticleStore.getState()\n      await readArticle(filePath)\n      \n      // 跳转到对应平台页面\n      router.push(isMobileRoute ? '/mobile/writing' : '/core/main')\n    }\n    \n    setupAndNavigate()\n  }\n\n  useEffect(() => {\n    if (open) {\n      loadAllArticle()\n      fetchAllMarks()\n      fetchTags()\n    }\n  }, [open])\n\n  useEffect(() => {\n    const loadSearchFilter = async () => {\n      const store = await Store.load('store.json')\n      const savedFilter = await store.get<SearchFilter>('globalSearchFilter')\n      if (savedFilter === 'all' || savedFilter === 'record' || savedFilter === 'article') {\n        setSearchFilter(savedFilter)\n      }\n    }\n\n    loadSearchFilter()\n  }, [])\n\n  useEffect(() => {\n    const persistSearchFilter = async () => {\n      const store = await Store.load('store.json')\n      await store.set('globalSearchFilter', searchFilter)\n    }\n\n    persistSearchFilter()\n  }, [searchFilter])\n\n  useEffect(() => {\n    debouncedSearch(searchValue)\n  }, [searchValue, debouncedSearch])\n\n  useEffect(() => {\n    if (!open) return\n    const timer = setTimeout(() => {\n      searchInputRef.current?.focus()\n    }, 60)\n    return () => clearTimeout(timer)\n  }, [open, isMobileRoute])\n\n  const searchContent = (\n    <>\n      <div className=\"flex items-center gap-3 border-b border-border/70 px-4 py-3\">\n        <div className=\"min-w-0 flex-1\">\n          <CommandInput\n            ref={searchInputRef}\n            autoFocus\n            placeholder={t('search.placeholder')}\n            value={searchValue}\n            onValueChange={setSearchValue}\n            className=\"h-10 text-base font-medium\"\n          />\n        </div>\n        <div className=\"flex shrink-0 items-center gap-3\">\n          <div className=\"text-sm font-semibold tracking-tight text-foreground/90\">\n            {t('search.results', { count: filteredSearchResult.length })}\n          </div>\n          <Separator orientation=\"vertical\" className=\"h-5\" />\n          <div className=\"flex items-center gap-1 rounded-full border border-border/70 bg-muted/20 p-1\">\n            <Button\n              type=\"button\"\n              variant={searchFilter === 'all' ? 'secondary' : 'ghost'}\n              size=\"sm\"\n              className=\"h-7 rounded-full px-3 text-xs\"\n              onClick={() => setSearchFilter('all')}\n            >\n              {t('common.all')}\n            </Button>\n            <Button\n              type=\"button\"\n              variant={searchFilter === 'record' ? 'secondary' : 'ghost'}\n              size=\"sm\"\n              className=\"h-7 rounded-full px-3 text-xs\"\n              onClick={() => setSearchFilter('record')}\n            >\n              {t('search.item.record')}\n            </Button>\n            <Button\n              type=\"button\"\n              variant={searchFilter === 'article' ? 'secondary' : 'ghost'}\n              size=\"sm\"\n              className=\"h-7 rounded-full px-3 text-xs\"\n              onClick={() => setSearchFilter('article')}\n            >\n              {t('search.item.article')}\n            </Button>\n          </div>\n        </div>\n      </div>\n      <CommandList className={isMobileRoute ? \"h-[64vh] max-h-[64vh]\" : \"min-h-0 flex-1 max-h-none\"}>\n        {!searchValue && (\n          <Empty className=\"border-0\">\n            <EmptyHeader>\n              <SearchX className=\"size-10 text-muted-foreground\" />\n              <EmptyTitle>{t('search.placeholder')}</EmptyTitle>\n              <EmptyDescription>\n                {t('search.tryDifferentKeywords')}\n              </EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        )}\n        {filteredSearchResult.length === 0 && searchValue && (\n          <Empty className=\"border-0\">\n            <EmptyHeader>\n              <SearchX className=\"size-10 text-muted-foreground\" />\n              <EmptyTitle>{t('search.noResults')}</EmptyTitle>\n              <EmptyDescription>\n                {t('search.tryDifferentKeywords')}\n              </EmptyDescription>\n            </EmptyHeader>\n          </Empty>\n        )}\n        {searchResult.length > 0 && (\n          <CommandGroup>\n            <div className=\"flex flex-col divide-y divide-border/60\">\n              {filteredSearchResult.map((item) => {\n              const resultMeta = getResultMeta(item)\n              const MetaIcon = resultMeta.icon\n              return (\n                <CommandItem\n                  key={item.id}\n                  value={`${item.searchType}-${item.title || item.path}`}\n                  onSelect={() => handleSelect(item)}\n                  className={cn(\n                    isMobileRoute\n                      ? \"group flex flex-col items-start gap-0 rounded-none bg-transparent p-0 text-left data-[selected=true]:bg-muted/30\"\n                      : \"group flex flex-col items-start gap-0 rounded-none bg-transparent p-0 text-left data-[selected=true]:bg-muted/30\"\n                  )}\n                >\n                  {isMobileRoute ? (\n                    <div className=\"w-full py-3\">\n                      <div className=\"flex items-start gap-3 px-2 py-2 transition-colors group-data-[selected=true]:bg-muted/30\">\n                        <div className={cn(\"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg\", getResultTone(item))}>\n                          {item.searchType === 'record' ? (\n                            <NotebookPen className=\"size-3.5\" />\n                          ) : (\n                            <File className=\"size-3.5\" />\n                          )}\n                        </div>\n\n                        <div className=\"flex min-w-0 flex-1 flex-col gap-2.5\">\n                          <div className=\"flex min-w-0 items-start justify-between gap-3\">\n                            <div className=\"min-w-0 flex-1\">\n                              {item.title ? (\n                                <div className=\"truncate text-[14px] font-semibold tracking-tight text-foreground\">\n                                  {highlightText(item.title, searchValue)}\n                                </div>\n                              ) : null}\n                            </div>\n\n                            <div className=\"flex shrink-0 flex-wrap items-center justify-end gap-1.5\">\n                                {item.type ? (\n                                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0.5 text-[10px] capitalize\">\n                                    {item.type}\n                                  </Badge>\n                                ) : null}\n                                <div className=\"flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground\">\n                                  <MetaIcon className=\"size-3 shrink-0\" />\n                                  <span className=\"max-w-[120px] truncate\">{resultMeta.primary}</span>\n                                </div>\n                            </div>\n                          </div>\n\n                          <div className=\"line-clamp-2 text-xs leading-5 text-muted-foreground\">\n                            {highlightText(item.highlightText, searchValue)}\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  ) : (\n                    <div className=\"w-full py-3\">\n                      <div className=\"flex items-start gap-3 px-2 py-2 transition-colors group-data-[selected=true]:bg-muted/30\">\n                        <div className={cn(\"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg\", getResultTone(item))}>\n                          {item.searchType === 'record' ? (\n                            <NotebookPen className=\"size-3.5\" />\n                          ) : (\n                            <File className=\"size-3.5\" />\n                          )}\n                        </div>\n\n                        <div className=\"flex min-w-0 flex-1 flex-col gap-2.5\">\n                          <div className=\"flex min-w-0 items-start justify-between gap-4\">\n                            <div className=\"min-w-0 flex-1\">\n                              {item.title && (\n                                <div className=\"truncate text-[14px] font-semibold tracking-tight text-foreground\">\n                                  {highlightText(item.title, searchValue)}\n                                </div>\n                              )}\n                            </div>\n\n                            <div className=\"flex shrink-0 flex-wrap items-center justify-end gap-1.5\">\n                                {item.type ? (\n                                  <Badge variant=\"outline\" className=\"rounded-full px-2 py-0.5 text-[10px] capitalize\">\n                                    {item.type}\n                                  </Badge>\n                                ) : null}\n                                <div className=\"flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground\">\n                                  <MetaIcon className=\"size-3 shrink-0\" />\n                                  <span className=\"max-w-[180px] truncate\">{resultMeta.primary}</span>\n                                </div>\n                            </div>\n                          </div>\n\n                          <div className=\"line-clamp-2 text-[12px] leading-5 text-muted-foreground\">\n                            {highlightText(item.highlightText, searchValue)}\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </CommandItem>\n              )\n            })}\n            </div>\n          </CommandGroup>\n        )}\n      </CommandList>\n    </>\n  )\n\n  if (isMobileRoute) {\n    return (\n      <Drawer open={open} onOpenChange={onOpenChange}>\n        <DrawerContent className=\"h-[88vh] rounded-t-[28px] border-border/70 bg-background p-0 shadow-2xl\">\n          <div className=\"min-h-0 flex-1 px-3 pb-3 pt-3\">\n        <Command\n          shouldFilter={false}\n          className={cn(\n            \"h-full rounded-[22px] border border-border/70 bg-background shadow-sm\",\n            \"[&_[cmdk-group-heading]]:px-4 [&_[cmdk-group-heading]]:py-3 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:tracking-tight [&_[cmdk-group-heading]]:text-foreground/85\",\n                \"[&_[cmdk-group]]:px-0 [&_[cmdk-input-wrapper]]:border-0 [&_[cmdk-input-wrapper]]:bg-transparent [&_[cmdk-input-wrapper]]:px-0\",\n                \"[&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input-wrapper]_svg]:text-muted-foreground\",\n                \"[&_[cmdk-input]]:h-10 [&_[cmdk-input]]:text-base [&_[cmdk-input]]:font-medium [&_[cmdk-input]]:tracking-tight [&_[cmdk-input]]:placeholder:text-muted-foreground/60\",\n                \"[&_[cmdk-list]]:px-0 [&_[cmdk-list]]:py-2 [&_[cmdk-item]]:rounded-2xl [&_[cmdk-item]]:px-0 [&_[cmdk-item]]:py-0\"\n              )}\n            >\n              {searchContent}\n            </Command>\n          </div>\n        </DrawerContent>\n      </Drawer>\n    )\n  }\n\n  return (\n      <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent showCloseButton={false} className=\"h-[56vh] max-h-[56vh] max-w-4xl overflow-hidden border-border/70 bg-background p-0 shadow-2xl\">\n        <DialogTitle className=\"sr-only\">{t('search.placeholder')}</DialogTitle>\n        <Command\n          shouldFilter={false}\n          className={cn(\n            \"h-full bg-transparent\",\n            \"[&_[cmdk-group-heading]]:px-5 [&_[cmdk-group-heading]]:py-3 [&_[cmdk-group-heading]]:text-base [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:tracking-tight [&_[cmdk-group-heading]]:text-foreground/85\",\n            \"[&_[cmdk-group]]:px-0 [&_[cmdk-input-wrapper]]:border-0 [&_[cmdk-input-wrapper]]:bg-transparent [&_[cmdk-input-wrapper]]:px-0\",\n            \"[&_[cmdk-input-wrapper]_svg]:size-4 [&_[cmdk-input-wrapper]_svg]:text-muted-foreground\",\n            \"[&_[cmdk-input]]:h-10 [&_[cmdk-input]]:text-base [&_[cmdk-input]]:font-medium [&_[cmdk-input]]:tracking-tight [&_[cmdk-input]]:placeholder:text-muted-foreground/60\",\n            \"[&_[cmdk-list]]:px-0 [&_[cmdk-list]]:py-2\"\n          )}\n        >\n          {searchContent}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "src/components/simple-mobile-tool.tsx",
    "content": "'use client'\n\nimport { Button } from \"@/components/ui/button\"\nimport { CopySlash, Mic, ImagePlus, Link, FileText, SquarePen } from \"lucide-react\"\nimport { useTranslations } from \"next-intl\"\n\ninterface SimpleMobileToolProps {\n  toolId: string\n  onToolClick?: (toolId: string) => void\n}\n\nexport function SimpleMobileTool({ toolId, onToolClick }: SimpleMobileToolProps) {\n  const t = useTranslations()\n\n  const getToolInfo = (id: string) => {\n    switch (id) {\n      case 'text':\n        return { icon: <CopySlash className=\"w-5 h-5\" />, label: t('record.mark.type.text') }\n      case 'recording':\n        return { icon: <Mic className=\"w-5 h-5\" />, label: t('record.mark.type.recording') }\n      case 'image':\n        return { icon: <ImagePlus className=\"w-5 h-5\" />, label: t('record.mark.type.image') }\n      case 'link':\n        return { icon: <Link className=\"w-5 h-5\" />, label: t('record.mark.type.link') }\n      case 'file':\n        return { icon: <FileText className=\"w-5 h-5\" />, label: t('record.mark.type.file') }\n      case 'write':\n        return { icon: <SquarePen className=\"w-5 h-5\" />, label: t('navigation.write') }\n      default:\n        return { icon: null, label: '' }\n    }\n  }\n\n  const toolInfo = getToolInfo(toolId)\n\n  const handleClick = () => {\n    if (onToolClick) {\n      onToolClick(toolId)\n    }\n  }\n\n  return (\n    <Button\n      variant=\"ghost\"\n      onClick={handleClick}\n      className=\"flex h-auto min-h-16 min-w-14 flex-col items-center justify-center gap-1 rounded-xl px-2 py-2 hover:bg-accent\"\n      aria-label={toolInfo.label}\n      title={toolInfo.label}\n    >\n      <div className=\"text-primary\">\n        {toolInfo.icon}\n      </div>\n      <span className=\"text-[11px] leading-none text-muted-foreground\">{toolInfo.label}</span>\n    </Button>\n  )\n}\n"
  },
  {
    "path": "src/components/sync-confirm-dialog.tsx",
    "content": "'use client'\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport {\n  Drawer,\n  DrawerContent,\n  DrawerDescription,\n  DrawerFooter,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\"\nimport { Button } from \"@/components/ui/button\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Calendar, User, GitMerge, ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react'\nimport dayjs from 'dayjs'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport 'dayjs/locale/zh-cn'\nimport 'dayjs/locale/en'\nimport 'dayjs/locale/ja'\nimport 'dayjs/locale/pt-br'\nimport { useI18n } from '@/hooks/useI18n'\nimport { useSyncConfirmStore } from '@/stores/sync-confirm'\nimport { useIsMobile } from '@/hooks/use-mobile'\nimport { isMobileDevice as checkIsMobileDevice } from '@/lib/check'\nimport { cn } from '@/lib/utils'\nimport emitter from '@/lib/emitter'\nimport { getSyncPushQueue } from '@/lib/sync/sync-push-queue'\nimport { useEffect } from 'react'\n\n// 初始化 dayjs 插件\ndayjs.extend(relativeTime)\n\nexport function SyncConfirmDialog() {\n  const { currentLocale } = useI18n()\n  const isMobile = useIsMobile() || checkIsMobileDevice()\n  const {\n    isOpen,\n    dialogType,\n    fileName,\n    commitInfo,\n    localSha,\n    remoteSha,\n    onConfirm,\n    onCancel,\n    onKeepLocal,\n    onMerge,\n    onIgnore,\n    hideConfirmDialog,\n    showShaMismatchDialog\n  } = useSyncConfirmStore()\n\n  // 监听 SHA 不匹配事件，显示确认对话框\n  useEffect(() => {\n    const handleShaMismatch = async (data: { path: string; localSha?: string; remoteSha?: string }) => {\n      const fileName = data.path.split('/').pop() || data.path\n      const syncPushQueue = getSyncPushQueue()\n\n      showShaMismatchDialog({\n        fileName,\n        localSha: data.localSha,\n        remoteSha: data.remoteSha,\n        onForceUpload: async () => {\n          // 用户确认强制上传\n          await syncPushQueue.forcePush(data.path)\n        },\n        onCancel: () => {\n          // 用户取消，不做任何操作\n        }\n      })\n    }\n\n    emitter.on('sync-sha-mismatch', handleShaMismatch)\n\n    return () => {\n      emitter.off('sync-sha-mismatch', handleShaMismatch)\n    }\n  }, [showShaMismatchDialog])\n\n  const getLocale = () => {\n    switch (currentLocale) {\n      case 'zh': return 'zh-cn'\n      case 'ja': return 'ja'\n      case 'pt-BR': return 'pt-br'\n      default: return 'en'\n    }\n  }\n\n  const formatDate = (date: Date) => {\n    return dayjs(date).locale(getLocale()).fromNow()\n  }\n\n  const handleConfirm = () => {\n    onConfirm?.()\n    hideConfirmDialog()\n  }\n\n  const handleCancel = () => {\n    onCancel?.()\n    hideConfirmDialog()\n  }\n\n  const handleKeepLocal = () => {\n    onKeepLocal?.()\n    hideConfirmDialog()\n  }\n\n  const handleMerge = () => {\n    onMerge?.()\n    hideConfirmDialog()\n  }\n\n  const handleIgnore = () => {\n    onIgnore?.()\n    hideConfirmDialog()\n  }\n\n  const isPullDialog = dialogType === 'pull'\n  const isConflictDialog = dialogType === 'conflict'\n  const isShaMismatchDialog = dialogType === 'shaMismatch'\n\n  return (\n    <>\n      {isMobile ? (\n        <Drawer open={isOpen} onOpenChange={hideConfirmDialog}>\n          <DrawerContent className=\"max-h-[85vh]\">\n            {isPullDialog && (\n              <>\n                <DrawerHeader>\n                  <DrawerTitle className=\"flex items-center gap-2\">\n                    <ArrowDownToLine className=\"h-5 w-5\" />\n                    检测到远程文件更新\n                  </DrawerTitle>\n                  <DrawerDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 有远程更新\n                  </DrawerDescription>\n                </DrawerHeader>\n\n                <div className=\"space-y-4 px-4 overflow-y-auto\">\n                  {commitInfo && (\n                    <div className=\"space-y-3\">\n                      <div className=\"flex items-center gap-2\">\n                        <h4 className=\"text-sm font-medium\">最新提交信息</h4>\n                        <Badge variant=\"outline\" className=\"text-xs\">\n                          {commitInfo.sha.slice(0, 7)}\n                        </Badge>\n                      </div>\n\n                      <div className=\"bg-muted/30 p-4 rounded-lg space-y-3\">\n                        <div>\n                          <p className=\"text-sm font-medium mb-1\">提交消息</p>\n                          <p className=\"text-sm\">{commitInfo.message}</p>\n                        </div>\n\n                        <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground gap-2\">\n                          <div className=\"flex items-center gap-4\">\n                            <div className=\"flex items-center gap-1\">\n                              <User className=\"h-4 w-4\" />\n                              {commitInfo.author}\n                            </div>\n                            <div className=\"flex items-center gap-1\">\n                              <Calendar className=\"h-4 w-4\" />\n                              {formatDate(commitInfo.date)}\n                            </div>\n                          </div>\n\n                          {(commitInfo.additions !== undefined || commitInfo.deletions !== undefined) && (\n                            <div className=\"flex items-center gap-2\">\n                              {commitInfo.additions !== undefined && commitInfo.additions > 0 && (\n                                <Badge variant=\"default\" className=\"text-xs bg-green-100 text-green-800\">\n                                  +{commitInfo.additions}\n                                </Badge>\n                              )}\n                              {commitInfo.deletions !== undefined && commitInfo.deletions > 0 && (\n                                <Badge variant=\"destructive\" className=\"text-xs\">\n                                  -{commitInfo.deletions}\n                                </Badge>\n                              )}\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </div>\n\n                <DrawerFooter className=\"flex-row gap-2\">\n                  {onIgnore && (\n                    <Button variant=\"outline\" onClick={handleIgnore} className=\"flex-1\">\n                      忽略\n                    </Button>\n                  )}\n                  <Button variant=\"outline\" onClick={handleCancel} className=\"flex-1\">\n                    取消\n                  </Button>\n                  <Button onClick={handleConfirm} className=\"flex-1\">\n                    确认拉取\n                  </Button>\n                </DrawerFooter>\n              </>\n            )}\n\n            {isConflictDialog && (\n              <>\n                <DrawerHeader>\n                  <DrawerTitle className=\"flex items-center gap-2\">\n                    <GitMerge className=\"h-5 w-5\" />\n                    文件冲突检测\n                  </DrawerTitle>\n                  <DrawerDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 存在冲突\n                  </DrawerDescription>\n                </DrawerHeader>\n\n                <div className=\"space-y-4 px-4 overflow-y-auto\">\n                  {commitInfo && (\n                    <div className=\"space-y-3\">\n                      <div className=\"flex items-center gap-2\">\n                        <h4 className=\"text-sm font-medium\">远程版本信息</h4>\n                        <Badge variant=\"outline\" className=\"text-xs\">\n                          {commitInfo.sha.slice(0, 7)}\n                        </Badge>\n                      </div>\n\n                      <div className=\"bg-muted/30 p-4 rounded-lg space-y-2\">\n                        <div>\n                          <p className=\"text-sm font-medium mb-1\">提交消息</p>\n                          <p className=\"text-sm\">{commitInfo.message}</p>\n                        </div>\n                        <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n                          <div className=\"flex items-center gap-1\">\n                            <User className=\"h-4 w-4\" />\n                            {commitInfo.author}\n                          </div>\n                          <div className=\"flex items-center gap-1\">\n                            <Calendar className=\"h-4 w-4\" />\n                            {formatDate(commitInfo.date)}\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n\n                  <div className=\"bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-4 rounded-lg\">\n                    <p className=\"text-sm text-yellow-800 dark:text-yellow-200\">\n                      请选择如何处理此冲突：保留本地版本、保留远程版本，或取消后手动合并。\n                    </p>\n                  </div>\n                </div>\n\n                <DrawerFooter className=\"flex-col gap-2\">\n                  <div className=\"grid grid-cols-2 gap-2 w-full\">\n                    <Button variant=\"outline\" onClick={handleKeepLocal} className=\"gap-2\">\n                      <ArrowUpFromLine className=\"h-4 w-4\" />\n                      保留本地\n                    </Button>\n                    <Button variant=\"default\" onClick={handleConfirm} className=\"gap-2\">\n                      <ArrowDownToLine className=\"h-4 w-4\" />\n                      保留远程\n                    </Button>\n                  </div>\n                  {onMerge && (\n                    <Button variant=\"secondary\" onClick={handleMerge} className=\"w-full gap-2\">\n                      <GitMerge className=\"h-4 w-4\" />\n                      合并两者\n                    </Button>\n                  )}\n                  <Button variant=\"ghost\" onClick={handleCancel} className=\"w-full gap-2\">\n                    <X className=\"h-4 w-4\" />\n                    取消\n                  </Button>\n                </DrawerFooter>\n              </>\n            )}\n\n            {isShaMismatchDialog && (\n              <>\n                <DrawerHeader>\n                  <DrawerTitle className=\"flex items-center gap-2\">\n                    <GitMerge className=\"h-5 w-5\" />\n                    同步冲突检测\n                  </DrawerTitle>\n                  <DrawerDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 推送失败\n                  </DrawerDescription>\n                </DrawerHeader>\n\n                <div className=\"space-y-4 px-4 overflow-y-auto\">\n                  <div className=\"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4 rounded-lg\">\n                    <p className=\"text-sm text-red-800 dark:text-red-200\">\n                      远程文件的 SHA 与本地记录不一致，可能已被其他设备修改。\n                    </p>\n                  </div>\n\n                  <div className=\"space-y-3\">\n                    <div className=\"flex items-center justify-between text-sm\">\n                      <span className=\"text-muted-foreground\">本地记录 SHA：</span>\n                      <code className=\"bg-muted px-2 py-1 rounded text-xs\">\n                        {localSha ? localSha.slice(0, 7) : '无'}\n                      </code>\n                    </div>\n                    <div className=\"flex items-center justify-between text-sm\">\n                      <span className=\"text-muted-foreground\">远程文件 SHA：</span>\n                      <code className=\"bg-muted px-2 py-1 rounded text-xs\">\n                        {remoteSha ? remoteSha.slice(0, 7) : '无'}\n                      </code>\n                    </div>\n                  </div>\n                </div>\n\n                <DrawerFooter className=\"flex-row gap-2\">\n                  <Button variant=\"outline\" onClick={handleCancel} className=\"flex-1\">\n                    取消\n                  </Button>\n                  <Button variant=\"destructive\" onClick={handleConfirm} className=\"flex-1 gap-2\">\n                    <ArrowUpFromLine className=\"h-4 w-4\" />\n                    强制上传\n                  </Button>\n                </DrawerFooter>\n              </>\n            )}\n          </DrawerContent>\n        </Drawer>\n      ) : (\n        <Dialog open={isOpen} onOpenChange={hideConfirmDialog}>\n          <DialogContent className=\"max-w-2xl\">\n            {isPullDialog && (\n              <>\n                <DialogHeader>\n                  <DialogTitle className=\"flex items-center gap-2\">\n                    <ArrowDownToLine className=\"h-5 w-5\" />\n                    检测到远程文件更新\n                  </DialogTitle>\n                  <DialogDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 有远程更新\n                  </DialogDescription>\n                </DialogHeader>\n\n                <div className=\"space-y-4 py-4\">\n                  {commitInfo && (\n                    <div className=\"space-y-3\">\n                      <div className=\"flex items-center gap-2\">\n                        <h4 className=\"text-sm font-medium\">最新提交信息</h4>\n                        <Badge variant=\"outline\" className=\"text-xs\">\n                          {commitInfo.sha.slice(0, 7)}\n                        </Badge>\n                      </div>\n\n                      <div className=\"bg-muted/30 p-4 rounded-lg space-y-3\">\n                        <div>\n                          <p className=\"text-sm font-medium mb-1\">提交消息</p>\n                          <p className=\"text-sm\">{commitInfo.message}</p>\n                        </div>\n\n                        <div className=\"flex items-center justify-between text-sm text-muted-foreground\">\n                          <div className=\"flex items-center gap-4\">\n                            <div className=\"flex items-center gap-1\">\n                              <User className=\"h-4 w-4\" />\n                              {commitInfo.author}\n                            </div>\n                            <div className=\"flex items-center gap-1\">\n                              <Calendar className=\"h-4 w-4\" />\n                              {formatDate(commitInfo.date)}\n                            </div>\n                          </div>\n\n                          {(commitInfo.additions !== undefined || commitInfo.deletions !== undefined) && (\n                            <div className=\"flex items-center gap-2\">\n                              {commitInfo.additions !== undefined && commitInfo.additions > 0 && (\n                                <Badge variant=\"default\" className=\"text-xs bg-green-100 text-green-800\">\n                                  +{commitInfo.additions}\n                                </Badge>\n                              )}\n                              {commitInfo.deletions !== undefined && commitInfo.deletions > 0 && (\n                                <Badge variant=\"destructive\" className=\"text-xs\">\n                                  -{commitInfo.deletions}\n                                </Badge>\n                              )}\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    </div>\n                  )}\n                </div>\n\n                <DialogFooter>\n                  {onIgnore && (\n                    <Button variant=\"outline\" onClick={handleIgnore}>\n                      忽略\n                    </Button>\n                  )}\n                  <Button variant=\"outline\" onClick={handleCancel}>\n                    取消\n                  </Button>\n                  <Button onClick={handleConfirm}>\n                    确认拉取\n                  </Button>\n                </DialogFooter>\n              </>\n            )}\n\n            {isConflictDialog && (\n              <>\n                <DialogHeader>\n                  <DialogTitle className=\"flex items-center gap-2\">\n                    <GitMerge className=\"h-5 w-5\" />\n                    文件冲突检测\n                  </DialogTitle>\n                  <DialogDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 存在冲突\n                  </DialogDescription>\n                </DialogHeader>\n\n                <div className=\"space-y-4 py-4\">\n                  {commitInfo && (\n                    <div className=\"space-y-3\">\n                      <div className=\"flex items-center gap-2\">\n                        <h4 className=\"text-sm font-medium\">远程版本信息</h4>\n                        <Badge variant=\"outline\" className=\"text-xs\">\n                          {commitInfo.sha.slice(0, 7)}\n                        </Badge>\n                      </div>\n\n                      <div className=\"bg-muted/30 p-4 rounded-lg space-y-2\">\n                        <div>\n                          <p className=\"text-sm font-medium mb-1\">提交消息</p>\n                          <p className=\"text-sm\">{commitInfo.message}</p>\n                        </div>\n                        <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n                          <div className=\"flex items-center gap-1\">\n                            <User className=\"h-4 w-4\" />\n                            {commitInfo.author}\n                          </div>\n                          <div className=\"flex items-center gap-1\">\n                            <Calendar className=\"h-4 w-4\" />\n                            {formatDate(commitInfo.date)}\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  )}\n\n                  <div className={cn(\n                    \"p-4 rounded-lg\",\n                    \"bg-yellow-50 dark:bg-yellow-900/20\",\n                    \"border border-yellow-200 dark:border-yellow-800\"\n                  )}>\n                    <p className=\"text-sm text-yellow-800 dark:text-yellow-200\">\n                      请选择如何处理此冲突：保留本地版本、保留远程版本，或取消后手动合并。\n                    </p>\n                  </div>\n                </div>\n\n                <DialogFooter className=\"gap-2\">\n                  <Button variant=\"outline\" onClick={handleKeepLocal} className=\"gap-2\">\n                    <ArrowUpFromLine className=\"h-4 w-4\" />\n                    保留本地\n                  </Button>\n                  {onMerge && (\n                    <Button variant=\"secondary\" onClick={handleMerge} className=\"gap-2\">\n                      <GitMerge className=\"h-4 w-4\" />\n                      合并两者\n                    </Button>\n                  )}\n                  <Button variant=\"default\" onClick={handleConfirm} className=\"gap-2\">\n                    <ArrowDownToLine className=\"h-4 w-4\" />\n                    保留远程\n                  </Button>\n                  <Button variant=\"ghost\" onClick={handleCancel} className=\"gap-2\">\n                    <X className=\"h-4 w-4\" />\n                    取消\n                  </Button>\n                </DialogFooter>\n              </>\n            )}\n\n            {isShaMismatchDialog && (\n              <>\n                <DialogHeader>\n                  <DialogTitle className=\"flex items-center gap-2\">\n                    <GitMerge className=\"h-5 w-5\" />\n                    同步冲突检测\n                  </DialogTitle>\n                  <DialogDescription>\n                    文件 <span className=\"font-mono bg-muted px-1 rounded\">{fileName}</span> 推送失败\n                  </DialogDescription>\n                </DialogHeader>\n\n                <div className=\"space-y-4 py-4\">\n                  <div className={cn(\n                    \"p-4 rounded-lg\",\n                    \"bg-red-50 dark:bg-red-900/20\",\n                    \"border border-red-200 dark:border-red-800\"\n                  )}>\n                    <p className=\"text-sm text-red-800 dark:text-red-200\">\n                      远程文件的 SHA 与本地记录不一致，可能已被其他设备修改。\n                    </p>\n                  </div>\n\n                  <div className=\"space-y-3\">\n                    <div className=\"flex items-center justify-between text-sm\">\n                      <span className=\"text-muted-foreground\">本地记录 SHA：</span>\n                      <code className=\"bg-muted px-2 py-1 rounded text-xs\">\n                        {localSha ? localSha.slice(0, 7) : '无'}\n                      </code>\n                    </div>\n                    <div className=\"flex items-center justify-between text-sm\">\n                      <span className=\"text-muted-foreground\">远程文件 SHA：</span>\n                      <code className=\"bg-muted px-2 py-1 rounded text-xs\">\n                        {remoteSha ? remoteSha.slice(0, 7) : '无'}\n                      </code>\n                    </div>\n                  </div>\n                </div>\n\n                <DialogFooter>\n                  <Button variant=\"outline\" onClick={handleCancel}>\n                    取消\n                  </Button>\n                  <Button variant=\"destructive\" onClick={handleConfirm} className=\"gap-2\">\n                    <ArrowUpFromLine className=\"h-4 w-4\" />\n                    强制上传（覆盖远程）\n                  </Button>\n                </DialogFooter>\n              </>\n            )}\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/components/sync-status-badge.tsx",
    "content": "'use client'\n\nimport * as React from 'react'\nimport { CheckCircle2, ArrowUpCircle, ArrowDownCircle, AlertTriangle, Loader2, CloudOff } from 'lucide-react'\nimport { Badge, BadgeProps } from '@/components/ui/badge'\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'\nimport { useSyncManager } from '@/hooks/use-sync-manager'\nimport { cn } from '@/lib/utils'\n\nexport type SyncStatusType = 'synced' | 'local_newer' | 'remote_newer' | 'conflict' | 'unknown' | 'syncing' | 'offline'\n\ninterface SyncStatusBadgeProps {\n  path?: string\n  showLabel?: boolean\n  className?: string\n  badgeProps?: BadgeProps\n}\n\nexport function SyncStatusBadge({ path, showLabel = false, className, badgeProps }: SyncStatusBadgeProps) {\n  const { status, lastSyncTime, isPending, checkStatus } = useSyncManager(path)\n  const [isLoading, setIsLoading] = React.useState(false)\n\n  const refreshStatus = async () => {\n    if (!path) return\n    setIsLoading(true)\n    try {\n      await checkStatus(path)\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const statusConfig = {\n    synced: {\n      icon: CheckCircle2,\n      label: '已同步',\n      color: 'bg-green-100 text-green-800 hover:bg-green-100',\n      iconColor: 'text-green-600',\n    },\n    local_newer: {\n      icon: ArrowUpCircle,\n      label: '待推送',\n      color: 'bg-blue-100 text-blue-800 hover:bg-blue-100',\n      iconColor: 'text-blue-600',\n    },\n    remote_newer: {\n      icon: ArrowDownCircle,\n      label: '有更新',\n      color: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',\n      iconColor: 'text-yellow-600',\n    },\n    conflict: {\n      icon: AlertTriangle,\n      label: '冲突',\n      color: 'bg-red-100 text-red-800 hover:bg-red-100',\n      iconColor: 'text-red-600',\n    },\n    unknown: {\n      icon: CloudOff,\n      label: '未同步',\n      color: 'bg-gray-100 text-gray-800 hover:bg-gray-100',\n      iconColor: 'text-gray-600',\n    },\n    syncing: {\n      icon: Loader2,\n      label: '同步中',\n      color: 'bg-blue-100 text-blue-800',\n      iconColor: 'text-blue-600 animate-spin',\n    },\n    offline: {\n      icon: CloudOff,\n      label: '离线',\n      color: 'bg-gray-100 text-gray-800',\n      iconColor: 'text-gray-600',\n    },\n  }\n\n  const currentStatus = status === 'syncing' ? 'syncing' : status || 'unknown'\n  const config = statusConfig[currentStatus]\n  const Icon = config.icon\n\n  const formatLastSyncTime = () => {\n    if (!lastSyncTime) return '暂无同步记录'\n    const date = new Date(lastSyncTime)\n    const now = new Date()\n    const diffMs = now.getTime() - date.getTime()\n    const diffMins = Math.floor(diffMs / 60000)\n\n    if (diffMins < 1) return '刚刚同步'\n    if (diffMins < 60) return `${diffMins} 分钟前`\n    if (diffMins < 1440) return `${Math.floor(diffMins / 60)} 小时前`\n    return date.toLocaleDateString('zh-CN')\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <Badge\n          variant=\"outline\"\n          className={cn(\n            'gap-1.5 cursor-pointer',\n            config.color,\n            className\n          )}\n          onClick={refreshStatus}\n          {...badgeProps}\n        >\n          {isLoading ? (\n            <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n          ) : (\n            <Icon className={cn('h-3.5 w-3.5', config.iconColor, status === 'syncing' && 'animate-spin')} />\n          )}\n          {showLabel && <span className=\"text-xs font-medium\">{config.label}</span>}\n          {isPending && (\n            <span className=\"relative flex h-2 w-2\">\n              <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75\"></span>\n              <span className=\"relative inline-flex rounded-full h-2 w-2 bg-blue-500\"></span>\n            </span>\n          )}\n        </Badge>\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\" className=\"max-w-xs\">\n        <div className=\"space-y-1\">\n          <p className=\"font-medium\">{config.label}</p>\n          {path && <p className=\"text-xs text-muted-foreground truncate\">{path}</p>}\n          <p className=\"text-xs text-muted-foreground\">{formatLastSyncTime()}</p>\n        </div>\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\n// 简化版本，只显示图标\nexport function SyncStatusIcon({ path, className }: { path?: string; className?: string }) {\n  return <SyncStatusBadge path={path} showLabel={false} className={className} />\n}\n\n// 带标签的版本\nexport function SyncStatusLabel({ path, className }: { path?: string; className?: string }) {\n  return <SyncStatusBadge path={path} showLabel={true} className={className} />\n}\n"
  },
  {
    "path": "src/components/theme-provider.tsx",
    "content": "\"use client\"\n \nimport * as React from \"react\"\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\"\n \nexport function ThemeProvider({\n  children,\n  ...props\n}: React.ComponentProps<typeof NextThemesProvider>) {\n  return <NextThemesProvider {...props}>{children}</NextThemesProvider>\n}"
  },
  {
    "path": "src/components/title-bar-toolbars/sync-toggle.tsx",
    "content": "\"use client\"\n\n/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */\n\nimport * as React from \"react\"\nimport { DownloadCloud, Loader2, UploadCloud, CloudSync, Download, Upload } from \"lucide-react\"\nimport { useTranslations } from 'next-intl'\nimport { useRouter } from 'next/navigation'\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { toast } from '@/hooks/use-toast'\nimport { useState, useEffect } from 'react'\nimport useMarkStore from \"@/stores/mark\"\nimport useTagStore from \"@/stores/tag\"\nimport useChatStore from \"@/stores/chat\"\nimport useArticleStore from \"@/stores/article\"\nimport useSettingStore from \"@/stores/setting\"\nimport useSyncStore from \"@/stores/sync\"\nimport { Store } from \"@tauri-apps/plugin-store\"\nimport { uint8ArrayToBase64, decodeBase64ToString } from \"@/lib/sync/github\"\nimport { getRemoteFileContent } from \"@/lib/sync/remote-file\"\nimport { getSyncRepoName } from \"@/lib/sync/repo-utils\"\nimport { getGiteaApiBaseUrl } from \"@/lib/sync/gitea\"\nimport { fetch } from '@tauri-apps/plugin-http'\n\n// GitLab 实例类型\nenum GitlabInstanceType {\n  OFFICIAL = 'official',\n  JIHULAB = 'jihulab',\n  SELF_HOSTED = 'self-hosted'\n}\n\n// GitLab 实例配置\nconst GITLAB_INSTANCES: Record<GitlabInstanceType, { name: string; baseUrl: string }> = {\n  [GitlabInstanceType.OFFICIAL]: {\n    name: 'GitLab',\n    baseUrl: 'https://gitlab.com'\n  },\n  [GitlabInstanceType.JIHULAB]: {\n    name: '极狐GitLab',\n    baseUrl: 'https://jihulab.com'\n  },\n  [GitlabInstanceType.SELF_HOSTED]: {\n    name: '自建 GitLab',\n    baseUrl: ''\n  }\n}\n\n// 获取 GitLab API 基础 URL\nasync function getGitlabApiBaseUrl(): Promise<string> {\n  const store = await Store.load('store.json')\n  const instanceType = await store.get<GitlabInstanceType>('gitlabInstanceType') || GitlabInstanceType.OFFICIAL\n\n  console.log('[getGitlabApiBaseUrl] instanceType:', instanceType)\n\n  if (instanceType === GitlabInstanceType.SELF_HOSTED) {\n    let customUrl = await store.get<string>('gitlabCustomUrl') || ''\n    console.log('[getGitlabApiBaseUrl] customUrl:', customUrl)\n    customUrl = customUrl.replace(/\\/+$/, '').trim()\n\n    if (!customUrl) {\n      throw new Error('自建 GitLab 实例的 URL 未配置')\n    }\n\n    // 用户使用 http://localhost:8080/ 这种本地地址，不需要添加 https://\n    const baseUrl = `${customUrl}/api/v4`\n    console.log('[getGitlabApiBaseUrl] Self-hosted baseUrl:', baseUrl)\n    return baseUrl\n  }\n\n  const instance = GITLAB_INSTANCES[instanceType]\n  if (!instance) {\n    console.log('[getGitlabApiBaseUrl] Unknown instanceType, using OFFICIAL')\n    // 未知类型，默认使用官方 GitLab\n    return `${GITLAB_INSTANCES[GitlabInstanceType.OFFICIAL].baseUrl}/api/v4`\n  }\n  return `${instance.baseUrl}/api/v4`\n}\nimport { s3Upload, s3Download, s3HeadObject, s3Delete, testS3Connection } from \"@/lib/sync/s3\"\nimport { webdavUpload, webdavDownload, webdavHeadObject, webdavDelete, testWebDAVConnection } from \"@/lib/sync/webdav\"\nimport { S3Config, WebDAVConfig, SyncPlatform } from \"@/types/sync\"\nimport { filterSyncData, mergeSyncData } from \"@/config/sync-exclusions\"\nimport { confirm, save, open } from \"@tauri-apps/plugin-dialog\"\nimport { invoke } from \"@tauri-apps/api/core\"\nimport { SyncStateEnum } from \"@/lib/sync/github.types\"\nimport dayjs from \"dayjs\"\nimport { isMobileDevice } from \"@/lib/check\"\n\n// ============ 通用辅助函数 ============\nfunction encodePath(path: string, filename?: string): string {\n  const fullPath = filename ? `${path}/${filename}` : path\n  return fullPath.replace(/\\s/g, '_').split('/').map(segment => encodeURIComponent(segment)).join('/')\n}\n\n// GitLab API 需要完整路径一起编码\nfunction encodeGitLabPath(path: string, filename?: string): string {\n  const fullPath = filename ? `${path}/${filename}` : path\n  return encodeURIComponent(fullPath)\n}\n\nasync function requestGitHub(method: string, url: string, body?: object) {\n  const store = await Store.load('store.json')\n  const accessToken = await store.get<string>('accessToken')\n\n  const headers = new Headers()\n  headers.append('Authorization', `Bearer ${accessToken}`)\n  headers.append('Accept', 'application/vnd.github+json')\n  headers.append('X-GitHub-Api-Version', '2022-11-28')\n  headers.append('Content-Type', 'application/json')\n\n  const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })\n\n  if (response.status >= 200 && response.status < 300) {\n    return method === 'GET' ? await response.json() : await response.json()\n  }\n  if (method === 'GET') return null\n\n  const errorData = await response.json()\n  throw { status: response.status, message: errorData.message || 'Request failed' }\n}\n\nasync function requestGitee(method: string, url: string, body?: object) {\n  const store = await Store.load('store.json')\n  const giteeAccessToken = await store.get<string>('giteeAccessToken')\n\n  const headers = new Headers()\n  headers.append('Content-Type', 'application/json')\n\n  const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })\n\n  if (response.status >= 200 && response.status < 300) {\n    return method === 'GET' ? await response.json() : await response.json()\n  }\n  if (method === 'GET') return null\n\n  const errorData = await response.json()\n  throw { status: response.status, message: errorData.message || 'Request failed' }\n}\n\nasync function requestGitLab(method: string, url: string, body?: object) {\n  const store = await Store.load('store.json')\n  const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n\n  console.log('[requestGitLab] URL:', url)\n  console.log('[requestGitLab] Method:', method)\n  console.log('[requestGitLab] Token exists:', !!gitlabAccessToken)\n  console.log('[requestGitLab] Token prefix:', gitlabAccessToken?.substring(0, 10))\n\n  const headers = new Headers()\n  headers.append('PRIVATE-TOKEN', gitlabAccessToken as string)\n  headers.append('Content-Type', 'application/json')\n\n  // 使用 @tauri-apps/plugin-http 的 fetch 避免 CORS 问题\n  const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')\n  const response = await tauriFetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })\n\n  console.log('[requestGitLab] Status:', response.status)\n\n  if (response.status >= 200 && response.status < 300) {\n    return method === 'GET' ? await response.json() : await response.json()\n  }\n  if (method === 'GET') return null\n\n  const errorData = await response.json()\n  console.log('[requestGitLab] Error:', errorData)\n  throw { status: response.status, message: errorData.message || 'Request failed' }\n}\n\nasync function requestGitea(method: string, url: string, body?: object) {\n  const store = await Store.load('store.json')\n  const giteaAccessToken = await store.get<string>('giteaAccessToken')\n\n  const headers = new Headers()\n  headers.append('Authorization', `token ${giteaAccessToken}`)\n  headers.append('Content-Type', 'application/json')\n\n  const response = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })\n\n  if (response.status >= 200 && response.status < 300) {\n    return method === 'GET' ? await response.json() : await response.json()\n  }\n  if (method === 'GET') return null\n\n  const errorData = await response.json()\n  throw { status: response.status, message: errorData.message || 'Request failed' }\n}\n\n// ============ GitHub 上传/下载函数 ============\nasync function githubUpload({ file, path, filename, sha, repo, accessToken, githubUsername }: {\n  file: string, path: string, filename: string, sha?: string, repo: string, accessToken: string, githubUsername: string\n}) {\n  const url = `https://api.github.com/repos/${githubUsername}/${repo}/contents/${encodePath(path, filename)}`\n  return requestGitHub('PUT', url, { message: `Upload ${filename}`, content: file, sha })\n}\n\nasync function githubGetFile({ path, repo, accessToken, githubUsername }: {\n  path: string, repo: string, accessToken: string, githubUsername: string\n}) {\n  const url = `https://api.github.com/repos/${githubUsername}/${repo}/contents/${encodePath(path)}`\n  return requestGitHub('GET', url)\n}\n\n// ============ Gitee 上传/下载函数 ============\nasync function giteeUpload({ file, path, filename, sha, repo, accessToken, giteeUsername }: {\n  file: string, path: string, filename: string, sha?: string, repo: string, accessToken: string, giteeUsername: string\n}) {\n  const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}/contents/${encodePath(path, filename)}`\n  return requestGitee(sha ? 'PUT' : 'POST', url, { access_token: accessToken, content: file, message: `Upload ${filename}`, branch: 'master', sha })\n}\n\nasync function giteeGetFile({ path, repo, accessToken, giteeUsername }: {\n  path: string, repo: string, accessToken: string, giteeUsername: string\n}) {\n  const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}/contents/${encodePath(path)}?access_token=${accessToken}`\n  return requestGitee('GET', url)\n}\n\n// ============ GitLab 上传/下载函数 ============\nasync function gitlabUpload({ file, path, filename, sha, accessToken, projectId }: {\n  file: string, path: string, filename: string, sha?: string, accessToken: string, projectId: string\n}) {\n  const baseUrl = await getGitlabApiBaseUrl()\n  const url = `${baseUrl}/projects/${projectId}/repository/files/${encodeGitLabPath(path, filename)}`\n\n  // 如果没有 sha，先尝试用 POST 创建\n  if (!sha) {\n    try {\n      return await requestGitLab('POST', url, { branch: 'main', content: file, commit_message: `Upload ${filename}`, encoding: 'base64' })\n    } catch (error: any) {\n      // 如果是 404 错误，说明文件不存在，先获取 SHA 后再上传\n      if (error.status === 404) {\n        const existingFile = await gitlabGetFile({ path: `${path}/${filename}`, accessToken, projectId })\n        if (existingFile) {\n          sha = existingFile.file_sha || existingFile.sha\n        }\n      }\n    }\n  }\n\n  // 如果有 sha，或者 POST 失败，用 PUT 更新\n  return requestGitLab('PUT', url, { branch: 'main', content: file, commit_message: `Upload ${filename}`, encoding: 'base64', sha })\n}\n\nasync function gitlabGetFile({ path, accessToken, projectId }: {\n  path: string, accessToken: string, projectId: string\n}) {\n  const baseUrl = await getGitlabApiBaseUrl()\n  const url = `${baseUrl}/projects/${projectId}/repository/files/${encodeGitLabPath(path)}?ref=main`\n  return requestGitLab('GET', url)\n}\n\n// ============ Gitea 上传/下载函数 ============\nasync function giteaUpload({ file, path, filename, sha, repo, accessToken, giteaUsername }: {\n  file: string, path: string, filename: string, sha?: string, repo: string, accessToken: string, giteaUsername: string\n}) {\n  const baseUrl = await getGiteaApiBaseUrl()\n  const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${encodePath(path, filename)}`\n\n  // 如果没有 sha，先尝试用 POST 创建\n  if (!sha) {\n    try {\n      return await requestGitea('POST', url, { content: file, message: `Upload ${filename}`, branch: 'main' })\n    } catch (error: any) {\n      // 如果是 422 错误，说明文件可能已存在，需要先获取 SHA\n      if (error.status === 422) {\n        const existingFile = await giteaGetFile({ path: `${path}/${filename}`, repo, accessToken, giteaUsername })\n        if (existingFile) {\n          sha = existingFile.sha\n        }\n      }\n    }\n  }\n\n  // 如果有 sha 或者 POST 失败，用 PUT 更新\n  return requestGitea('PUT', url, { content: file, message: `Upload ${filename}`, branch: 'main', sha })\n}\n\nasync function giteaGetFile({ path, repo, accessToken, giteaUsername }: {\n  path: string, repo: string, accessToken: string, giteaUsername: string\n}) {\n  const baseUrl = await getGiteaApiBaseUrl()\n  const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${encodePath(path)}?ref=main`\n  return requestGitea('GET', url)\n}\n\n// ============ 方案状态类型 ============\ntype ProviderStatus = 'connected' | 'disconnected' | 'failed' | 'unconfigured'\n\ninterface ProviderInfo {\n  platform: SyncPlatform\n  name: string\n  status: ProviderStatus\n}\n\nexport function SyncToggle() {\n  const t = useTranslations()\n  const router = useRouter()\n  const [syncing, setSyncing] = useState(false)\n  const [exporting, setExporting] = useState(false)\n  const [importing, setImporting] = useState(false)\n  const [popoverOpen, setPopoverOpen] = useState(false)\n  const [providers, setProviders] = useState<ProviderInfo[]>([])\n\n  const { primaryBackupMethod, setPrimaryBackupMethod } = useSettingStore()\n  const {\n    syncRepoState,\n    giteeSyncRepoState,\n    gitlabSyncProjectState,\n    giteaSyncRepoState,\n    s3Connected,\n    webdavConnected,\n    setS3Connected,\n    setWebDAVConnected\n  } = useSyncStore()\n\n  const { uploadMarks, downloadMarks, fetchMarks } = useMarkStore()\n  const { uploadTags, downloadTags, fetchTags, currentTagId } = useTagStore()\n  const { init } = useChatStore()\n  const { loadFileTree, loadRemoteSyncFiles } = useArticleStore()\n\n  const isMobile = isMobileDevice()\n\n  // 加载各平台状态并自动检测\n  useEffect(() => {\n    async function loadProviderStatus() {\n      try {\n        const store = await Store.load('store.json')\n        const accessToken = await store.get<string>('accessToken')\n        const giteeAccessToken = await store.get<string>('giteeAccessToken')\n        const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n        const giteaAccessToken = await store.get<string>('giteaAccessToken')\n        const githubUsername = await store.get<string>('githubUsername')\n        const giteeUsername = await store.get<string>('giteeUsername')\n        const giteaUsername = await store.get<string>('giteaUsername')\n        const gitlabProjectId = await store.get<string>(`gitlab_${await getSyncRepoName('gitlab')}_project_id`)\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n\n        // 移动端自动检测各平台状态\n        if (isMobile) {\n          // GitHub 检测\n          if (githubUsername && accessToken && syncRepoState === SyncStateEnum.fail) {\n            try {\n              const { checkSyncRepoState } = await import('@/lib/sync/github')\n              const repoName = await getSyncRepoName('github')\n              const syncRepo = await checkSyncRepoState(repoName)\n              if (syncRepo) {\n                useSyncStore.getState().setSyncRepoState(SyncStateEnum.success)\n              } else {\n                useSyncStore.getState().setSyncRepoState(SyncStateEnum.fail)\n              }\n            } catch {\n              useSyncStore.getState().setSyncRepoState(SyncStateEnum.fail)\n            }\n          }\n\n          // Gitee 检测\n          if (giteeUsername && giteeAccessToken && giteeSyncRepoState === SyncStateEnum.fail) {\n            try {\n              const { checkSyncRepoState } = await import('@/lib/sync/gitee')\n              const repoName = await getSyncRepoName('gitee')\n              const syncRepo = await checkSyncRepoState(repoName)\n              if (syncRepo) {\n                useSyncStore.getState().setGiteeSyncRepoState(SyncStateEnum.success)\n              } else {\n                useSyncStore.getState().setGiteeSyncRepoState(SyncStateEnum.fail)\n              }\n            } catch {\n              useSyncStore.getState().setGiteeSyncRepoState(SyncStateEnum.fail)\n            }\n          }\n\n          // GitLab 检测\n          if (gitlabProjectId && gitlabAccessToken && gitlabSyncProjectState === SyncStateEnum.fail) {\n            try {\n              const { checkSyncProjectState } = await import('@/lib/sync/gitlab')\n              const repoName = await getSyncRepoName('gitlab')\n              const syncRepo = await checkSyncProjectState(repoName)\n              if (syncRepo) {\n                useSyncStore.getState().setGitlabSyncProjectState(SyncStateEnum.success)\n              } else {\n                useSyncStore.getState().setGitlabSyncProjectState(SyncStateEnum.fail)\n              }\n            } catch {\n              useSyncStore.getState().setGitlabSyncProjectState(SyncStateEnum.fail)\n            }\n          }\n\n          // Gitea 检测\n          if (giteaUsername && giteaAccessToken && giteaSyncRepoState === SyncStateEnum.fail) {\n            try {\n              const { checkSyncRepoState } = await import('@/lib/sync/gitea')\n              const repoName = await getSyncRepoName('gitea')\n              const syncRepo = await checkSyncRepoState(repoName)\n              if (syncRepo) {\n                useSyncStore.getState().setGiteaSyncRepoState(SyncStateEnum.success)\n              } else {\n                useSyncStore.getState().setGiteaSyncRepoState(SyncStateEnum.fail)\n              }\n            } catch {\n              useSyncStore.getState().setGiteaSyncRepoState(SyncStateEnum.fail)\n            }\n          }\n        }\n\n      const providerList: ProviderInfo[] = []\n\n      // GitHub\n      let githubStatus: ProviderStatus = 'unconfigured'\n      if (githubUsername && accessToken) {\n        githubStatus = syncRepoState === SyncStateEnum.success ? 'connected' : syncRepoState === SyncStateEnum.fail ? 'failed' : 'disconnected'\n      }\n      providerList.push({ platform: 'github', name: 'GitHub', status: githubStatus })\n\n      // Gitee\n      let giteeStatus: ProviderStatus = 'unconfigured'\n      if (giteeUsername && giteeAccessToken) {\n        giteeStatus = giteeSyncRepoState === SyncStateEnum.success ? 'connected' : giteeSyncRepoState === SyncStateEnum.fail ? 'failed' : 'disconnected'\n      }\n      providerList.push({ platform: 'gitee', name: 'Gitee', status: giteeStatus })\n\n      // GitLab\n      let gitlabStatus: ProviderStatus = 'unconfigured'\n      if (gitlabProjectId && gitlabAccessToken) {\n        gitlabStatus = gitlabSyncProjectState === SyncStateEnum.success ? 'connected' : gitlabSyncProjectState === SyncStateEnum.fail ? 'failed' : 'disconnected'\n      }\n      providerList.push({ platform: 'gitlab', name: 'GitLab', status: gitlabStatus })\n\n      // Gitea\n      let giteaStatus: ProviderStatus = 'unconfigured'\n      if (giteaUsername && giteaAccessToken) {\n        giteaStatus = giteaSyncRepoState === SyncStateEnum.success ? 'connected' : giteaSyncRepoState === SyncStateEnum.fail ? 'failed' : 'disconnected'\n      }\n      providerList.push({ platform: 'gitea', name: 'Gitea', status: giteaStatus })\n\n      // S3\n      let s3Status: ProviderStatus = 'unconfigured'\n      if (s3Config?.bucket) {\n        s3Status = s3Connected ? 'connected' : 'failed'\n      }\n      providerList.push({ platform: 's3', name: 'S3', status: s3Status })\n\n      // WebDAV\n      let webdavStatus: ProviderStatus = 'unconfigured'\n      if (webdavConfig?.url && webdavConfig?.username && webdavConfig?.password) {\n        webdavStatus = webdavConnected ? 'connected' : 'failed'\n      }\n      providerList.push({ platform: 'webdav', name: 'WebDAV', status: webdavStatus })\n\n      setProviders(providerList)\n      } catch (error) {\n        console.error('[SyncToggle] Error loading provider status:', error)\n      }\n    }\n\n    // 检测 S3 连接状态\n    async function checkS3Status() {\n      const store = await Store.load('store.json')\n      const s3Config = await store.get<S3Config>('s3SyncConfig')\n      if (s3Config?.bucket) {\n        const isConnected = await testS3Connection(s3Config).catch(() => false)\n        setS3Connected(isConnected)\n      }\n    }\n\n    // 检测 WebDAV 连接状态\n    async function checkWebDAVStatus() {\n      const store = await Store.load('store.json')\n      const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n      if (webdavConfig?.url && webdavConfig?.username && webdavConfig?.password) {\n        const isConnected = await testWebDAVConnection(webdavConfig).catch(() => false)\n        setWebDAVConnected(isConnected)\n      }\n    }\n\n    loadProviderStatus()\n\n    // 弹窗打开时检测 S3 和 WebDAV 连接状态\n    if (popoverOpen) {\n      checkS3Status()\n      checkWebDAVStatus()\n    }\n  }, [popoverOpen, syncRepoState, giteeSyncRepoState, gitlabSyncProjectState, giteaSyncRepoState, s3Connected, webdavConnected])\n\n  // 获取当前方案的显示文本\n  const getCurrentProviderDisplay = () => {\n    const current = providers.find(p => p.platform === primaryBackupMethod)\n    if (!current) return ''\n\n    // 已配置时只显示名称，未配置时显示名称 + \"未配置\"\n    if (current.status === 'unconfigured') {\n      return `${current.name} ${t('settings.sync.status.unconfigured')}`\n    }\n    return current.name\n  }\n\n  // 获取状态图标\n  const getStatusIcon = (status: ProviderStatus) => {\n    if (status === 'connected') {\n      return <span className=\"text-green-500\">●</span>\n    } else if (status === 'failed') {\n      return <span className=\"text-red-500\">●</span>\n    } else if (status === 'disconnected') {\n      return <span className=\"text-yellow-500\">●</span>\n    }\n    return <span className=\"text-zinc-400\">○</span>\n  }\n\n  // 处理方案切换\n  const handleProviderChange = async (value: string) => {\n    const selectedProvider = providers.find(p => p.platform === value)\n\n    // 如果选择了未配置的方案，跳转到设置页面\n    if (selectedProvider?.status === 'unconfigured') {\n      await setPrimaryBackupMethod(value as SyncPlatform)\n      // 跳转到同步设置页面，区分移动端和 PC 端\n      const settingPath = isMobile ? '/mobile/setting/pages/sync' : '/core/setting?anchor=sync'\n      router.push(settingPath)\n      return\n    }\n\n    // 如果是 S3 或 WebDAV，切换后重新检测连接状态\n    if (value === 's3' || value === 'webdav') {\n      const store = await Store.load('store.json')\n      if (value === 's3') {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config?.bucket) {\n          const isConnected = await testS3Connection(s3Config).catch(() => false)\n          setS3Connected(isConnected)\n        }\n      } else if (value === 'webdav') {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig?.url && webdavConfig?.username && webdavConfig?.password) {\n          const isConnected = await testWebDAVConnection(webdavConfig).catch(() => false)\n          setWebDAVConnected(isConnected)\n        }\n      }\n    }\n\n    await setPrimaryBackupMethod(value as SyncPlatform)\n\n    // 切换方案后重新加载文件列表\n    await loadFileTree()\n    await loadRemoteSyncFiles()\n  }\n\n  // 上传到云端\n  async function uploadAll() {\n    const confirmRef = await confirm(t('settings.uploadStore.uploadConfirm'))\n    if (!confirmRef) return\n    setSyncing(true)\n\n    try {\n      const tagRes = await uploadTags()\n      const markRes = await uploadMarks()\n\n      const path = '.settings'\n      const filename = 'store.json'\n\n      const store = await Store.load('store.json');\n      const allSettings: Record<string, any> = {}\n      const entries = await store.entries()\n      for (const [key, value] of entries) {\n        allSettings[key] = value\n      }\n\n      const syncableSettings = filterSyncData(allSettings)\n      const filteredContent = JSON.stringify(syncableSettings, null, 2)\n      const file = new TextEncoder().encode(filteredContent)\n\n      const primaryBackupMethod = await store.get<string>('primaryBackupMethod')\n      const accessToken = await store.get<string>('accessToken')\n      const giteeAccessToken = await store.get<string>('giteeAccessToken')\n      const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n      const giteaAccessToken = await store.get<string>('giteaAccessToken')\n      const githubUsername = await store.get<string>('githubUsername')\n      const giteeUsername = await store.get<string>('giteeUsername')\n      const giteaUsername = await store.get<string>('giteaUsername')\n      const gitlabProjectId = await store.get<string>(`gitlab_${await getSyncRepoName('gitlab')}_project_id`)\n      let settingsRes;\n\n      switch (primaryBackupMethod) {\n        case 'github': {\n          const githubRepo = await getSyncRepoName('github')\n          const existingFile = await githubGetFile({ path: `${path}/${filename}`, repo: githubRepo, accessToken: accessToken!, githubUsername: githubUsername! })\n          settingsRes = await githubUpload({\n            file: uint8ArrayToBase64(file),\n            path,\n            filename,\n            sha: existingFile?.sha,\n            repo: githubRepo,\n            accessToken: accessToken!,\n            githubUsername: githubUsername!,\n          })\n          break;\n        }\n        case 'gitee': {\n          const giteeRepo = await getSyncRepoName('gitee')\n          const existingFile = await giteeGetFile({ path: `${path}/${filename}`, repo: giteeRepo, accessToken: giteeAccessToken!, giteeUsername: giteeUsername! })\n          settingsRes = await giteeUpload({\n            file: uint8ArrayToBase64(file),\n            path,\n            filename,\n            sha: existingFile?.sha,\n            repo: giteeRepo,\n            accessToken: giteeAccessToken!,\n            giteeUsername: giteeUsername!,\n          })\n          break;\n        }\n        case 'gitlab': {\n          console.log('[uploadAll] GitLab - path:', path, 'filename:', filename, 'projectId:', gitlabProjectId)\n          const existingFile = await gitlabGetFile({ path: `${path}/${filename}`, accessToken: gitlabAccessToken!, projectId: gitlabProjectId! })\n          console.log('[uploadAll] GitLab existingFile:', existingFile)\n          settingsRes = await gitlabUpload({\n            file: uint8ArrayToBase64(file),\n            path,\n            filename,\n            sha: existingFile?.sha,\n            accessToken: gitlabAccessToken!,\n            projectId: gitlabProjectId!,\n          })\n          break;\n        }\n        case 'gitea': {\n          const giteaRepo = await getSyncRepoName('gitea')\n          const existingFile = await giteaGetFile({ path: `${path}/${filename}`, repo: giteaRepo, accessToken: giteaAccessToken!, giteaUsername: giteaUsername! })\n          settingsRes = await giteaUpload({\n            file: uint8ArrayToBase64(file),\n            path,\n            filename,\n            sha: existingFile?.sha,\n            repo: giteaRepo,\n            accessToken: giteaAccessToken!,\n            giteaUsername: giteaUsername!,\n          })\n          break;\n        }\n        case 's3': {\n          const s3Config = await store.get<S3Config>('s3SyncConfig')\n          if (s3Config) {\n            const s3Key = `${path}/${filename}`\n            const existingFile = await s3HeadObject(s3Config, s3Key)\n            if (existingFile) {\n              await s3Delete(s3Config, s3Key)\n            }\n            const result = await s3Upload(s3Config, s3Key, filteredContent)\n            settingsRes = result ? { success: true } : null\n          }\n          break;\n        }\n        case 'webdav': {\n          const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n          if (webdavConfig) {\n            const webdavKey = `${path}/${filename}`\n            const existingFile = await webdavHeadObject(webdavConfig, webdavKey)\n            if (existingFile) {\n              await webdavDelete(webdavConfig, webdavKey)\n            }\n            const result = await webdavUpload(webdavConfig, webdavKey, filteredContent)\n            settingsRes = result ? { success: true } : null\n          }\n          break;\n        }\n      }\n\n      if (tagRes && markRes && settingsRes) {\n        toast({\n          description: t('record.mark.uploadSuccess'),\n        })\n      }\n    } catch (error) {\n      console.error('Upload failed:', error)\n      toast({\n        description: t('common.error'),\n        variant: 'destructive'\n      })\n    }\n\n    setSyncing(false)\n  }\n\n  // 从云端下载\n  async function downloadAll() {\n    const res = await confirm(t('settings.uploadStore.downloadConfirm'))\n    if (!res) return\n    setSyncing(true)\n\n    try {\n      const tagRes = await downloadTags()\n      const markRes = await downloadMarks()\n\n      if (tagRes && markRes) {\n        await fetchTags()\n        await fetchMarks()\n        init(currentTagId)\n      }\n\n      const path = '.settings'\n      const filename = 'store.json'\n      const store = await Store.load('store.json');\n\n      const localSettings: Record<string, any> = {}\n      const entries = await store.entries()\n      for (const [key, value] of entries) {\n        localSettings[key] = value\n      }\n\n      const primaryBackupMethod = await store.get<string>('primaryBackupMethod')\n      const accessToken = await store.get<string>('accessToken')\n      const giteeAccessToken = await store.get<string>('giteeAccessToken')\n      const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n      const giteaAccessToken = await store.get<string>('giteaAccessToken')\n      const githubUsername = await store.get<string>('githubUsername')\n      const giteeUsername = await store.get<string>('giteeUsername')\n      const giteaUsername = await store.get<string>('giteaUsername')\n      const gitlabProjectId = await store.get<string>(`gitlab_${await getSyncRepoName('gitlab')}_project_id`)\n      let remoteFile;\n\n      switch (primaryBackupMethod) {\n        case 'github': {\n          const githubRepo = await getSyncRepoName('github')\n          remoteFile = await githubGetFile({ path: `${path}/${filename}`, repo: githubRepo, accessToken: accessToken!, githubUsername: githubUsername! })\n          break;\n        }\n        case 'gitee': {\n          const giteeRepo = await getSyncRepoName('gitee')\n          remoteFile = await giteeGetFile({ path: `${path}/${filename}`, repo: giteeRepo, accessToken: giteeAccessToken!, giteeUsername: giteeUsername! })\n          break;\n        }\n        case 'gitlab': {\n          remoteFile = await gitlabGetFile({ path: `${path}/${filename}`, accessToken: gitlabAccessToken!, projectId: gitlabProjectId! })\n          break;\n        }\n        case 'gitea': {\n          const giteaRepo = await getSyncRepoName('gitea')\n          remoteFile = await giteaGetFile({ path: `${path}/${filename}`, repo: giteaRepo, accessToken: giteaAccessToken!, giteaUsername: giteaUsername! })\n          break;\n        }\n        case 's3': {\n          const s3Config = await store.get<S3Config>('s3SyncConfig')\n          if (s3Config) {\n            const s3Key = `${path}/${filename}`\n            const content = await s3Download(s3Config, s3Key)\n            if (content) {\n              remoteFile = { content }\n            }\n          }\n          break;\n        }\n        case 'webdav': {\n          const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n          if (webdavConfig) {\n            const webdavKey = `${path}/${filename}`\n            const content = await webdavDownload(webdavConfig, webdavKey)\n            if (content) {\n              remoteFile = { content }\n            }\n          }\n          break;\n        }\n      }\n\n      if (remoteFile) {\n        let remoteSettings: Record<string, any>\n        if (primaryBackupMethod === 's3' || primaryBackupMethod === 'webdav') {\n          const s3Content = (remoteFile as any).content?.content\n          remoteSettings = JSON.parse(s3Content)\n        } else {\n          const configJson = decodeBase64ToString(getRemoteFileContent(remoteFile, `${path}/${filename}`))\n          remoteSettings = JSON.parse(configJson)\n        }\n\n        const mergedSettings = mergeSyncData(localSettings, remoteSettings)\n\n        const keys = Object.keys(mergedSettings)\n        await Promise.allSettled(keys.map(async key => await store.set(key, mergedSettings[key])))\n        await store.save()\n\n        toast({\n          description: t('record.mark.downloadSuccess') + t('common.restartToApply'),\n        })\n      }\n    } catch (error) {\n      console.error('Download failed:', error)\n      toast({\n        description: t('common.error'),\n        variant: 'destructive'\n      })\n    }\n\n    setSyncing(false)\n  }\n\n  // 导出本地备份\n  async function handleExport() {\n    try {\n      setExporting(true);\n\n      let filePath: string;\n\n      if (isMobile) {\n        filePath = `note-gen-backup-${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.zip`;\n      } else {\n        const selectedPath = await save({\n          title: t('settings.backupSync.localBackup.exportDialog.title'),\n          defaultPath: `note-gen-backup-${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.zip`,\n          filters: [{\n            name: 'ZIP Files',\n            extensions: ['zip']\n          }]\n        });\n\n        if (!selectedPath) {\n          setExporting(false);\n          return;\n        }\n        filePath = selectedPath;\n      }\n\n      const savedPath = await invoke<string>('export_app_data', { outputPath: filePath });\n\n      toast({\n        title: t('settings.backupSync.localBackup.exportSuccess'),\n        description: isMobile\n          ? `文件已保存到: ${savedPath}\\n请在 Files App 中查看`\n          : savedPath,\n      });\n    } catch (error) {\n      console.error('Export failed:', error);\n      toast({\n        title: t('settings.backupSync.localBackup.exportError'),\n        description: (error as Error).message,\n        variant: \"destructive\",\n      });\n    } finally {\n      setExporting(false);\n    }\n  }\n\n  // 导入本地备份\n  async function handleImport() {\n    try {\n      setImporting(true);\n\n      if (isMobile) {\n        // 移动端 TODO: 需要实现文件选择\n        toast({\n          description: t('settings.backupSync.localBackup.importError'),\n          variant: \"destructive\",\n        });\n        setImporting(false);\n        return;\n      }\n\n      const filePath = await open({\n        title: t('settings.backupSync.localBackup.importDialog.title'),\n        multiple: false,\n        directory: false,\n        filters: [{\n          name: 'ZIP Files',\n          extensions: ['zip']\n        }]\n      });\n\n      if (!filePath) {\n        setImporting(false);\n        return;\n      }\n\n      await invoke('import_app_data', { zipPath: filePath });\n\n      const shouldRestart = await confirm(t('settings.backupSync.localBackup.restartConfirm'), {\n        title: t('settings.backupSync.localBackup.importSuccess'),\n        kind: 'info'\n      });\n\n      if (shouldRestart) {\n        const { relaunch } = await import('@tauri-apps/plugin-process')\n        await relaunch()\n      }\n    } catch (error) {\n      console.error('Import failed:', error);\n      toast({\n        title: t('settings.backupSync.localBackup.importError'),\n        description: (error as Error).message,\n        variant: \"destructive\",\n      });\n    } finally {\n      setImporting(false);\n    }\n  }\n\n  return (\n    <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <PopoverTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-8 w-8\"\n              disabled={syncing || exporting || importing}\n            >\n              {syncing || exporting || importing ? (\n                <Loader2 className=\"h-4 w-4 animate-spin\" />\n              ) : (\n                <CloudSync className=\"h-4 w-4\" />\n              )}\n            </Button>\n          </PopoverTrigger>\n        </TooltipTrigger>\n        <TooltipContent side=\"bottom\">\n          <p>{t('common.sync')}</p>\n        </TooltipContent>\n      </Tooltip>\n      <PopoverContent align=\"end\" className=\"w-72\">\n        <div className=\"space-y-4\">\n          {/* 记录与配置同步分隔线 */}\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-px flex-1 bg-zinc-200 dark:bg-zinc-700\"></div>\n            <span className=\"text-xs text-zinc-400\">{t('settings.sync.cloudSync')}</span>\n            <div className=\"h-px flex-1 bg-zinc-200 dark:bg-zinc-700\"></div>\n          </div>\n\n          {/* 方案选择器 */}\n          <div>\n            <Select value={primaryBackupMethod} onValueChange={handleProviderChange}>\n              <SelectTrigger className=\"w-full\">\n                <span className=\"flex items-center gap-2\">\n                  <span className=\"mr-2\">\n                    {getStatusIcon(providers.find(p => p.platform === primaryBackupMethod)?.status || 'unconfigured')}\n                  </span>\n                  <SelectValue placeholder={t('settings.sync.selectPlatform')}>\n                    {getCurrentProviderDisplay()}\n                  </SelectValue>\n                </span>\n              </SelectTrigger>\n              <SelectContent>\n                {providers.map((provider) => (\n                  <SelectItem key={provider.platform} value={provider.platform}>\n                    <span className=\"flex items-center gap-2\">\n                      <span>{provider.name}</span>\n                      {provider.status === 'unconfigured' && (\n                        <span className=\"text-zinc-400 text-xs ml-auto\">\n                          {t('settings.sync.status.unconfigured')}\n                        </span>\n                      )}\n                    </span>\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n\n          {/* 网络备份操作 */}\n          <div className=\"flex flex-col gap-2\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={uploadAll}\n              disabled={syncing}\n            >\n              <UploadCloud className=\"mr-2 h-4 w-4\" />\n              {t('settings.sync.uploadRecords')}\n            </Button>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={downloadAll}\n              disabled={syncing}\n            >\n              <DownloadCloud className=\"mr-2 h-4 w-4\" />\n              {t('settings.sync.downloadConfig')}\n            </Button>\n          </div>\n\n          {/* 分隔线 */}\n          <div className=\"flex items-center gap-2\">\n            <div className=\"h-px flex-1 bg-zinc-200 dark:bg-zinc-700\"></div>\n            <span className=\"text-xs text-zinc-400\">{t('settings.sync.localBackupAll')}</span>\n            <div className=\"h-px flex-1 bg-zinc-200 dark:bg-zinc-700\"></div>\n          </div>\n\n          {/* 本地备份操作 */}\n          <div className=\"flex flex-col gap-2\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleExport}\n              disabled={exporting}\n            >\n              <Download className=\"mr-2 h-4 w-4\" />\n              {t('settings.backupSync.localBackup.export.button')}\n            </Button>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleImport}\n              disabled={importing}\n            >\n              <Upload className=\"mr-2 h-4 w-4\" />\n              {t('settings.backupSync.localBackup.import.button')}\n            </Button>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n"
  },
  {
    "path": "src/components/title-bar.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { platform } from '@tauri-apps/plugin-os'\nimport { getCurrentWindow } from '@tauri-apps/api/window'\nimport { isMobileDevice } from '@/lib/check'\nimport { Search, Settings, Minus, Square, X, PanelLeft, PanelRight, SquarePen, Cog, CalendarDays } from 'lucide-react'\nimport { usePathname, useRouter } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { useSidebarStore } from '@/stores/sidebar'\nimport { PinToggle } from './pin-toggle'\nimport { SyncToggle } from './title-bar-toolbars/sync-toggle'\nimport AppStatus from './app-status'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { Button } from '@/components/ui/button'\nimport useSettingStore from '@/stores/setting'\nimport useArticleStore from '@/stores/article'\nimport useUpdateStore from '@/stores/update'\nimport React from 'react'\nimport { ControlText } from '@/app/core/main/mark/control-text'\nimport { ControlRecording } from '@/app/core/main/mark/control-recording'\nimport { ControlScan } from '@/app/core/main/mark/control-scan'\nimport { ControlImage } from '@/app/core/main/mark/control-image'\nimport { ControlLink } from '@/app/core/main/mark/control-link'\nimport { ControlFile } from '@/app/core/main/mark/control-file'\nimport { ControlTodo } from '@/app/core/main/mark/control-todo'\nimport {\n  DndContext,\n  closestCenter,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n  arrayMove,\n  SortableContext,\n  horizontalListSortingStrategy,\n} from '@dnd-kit/sortable'\nimport { DraggableToolbarItem } from './draggable-toolbar-item'\nimport { useToolbarShortcuts } from '@/hooks/use-toolbar-shortcuts'\n\ntype Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\ninterface TitleBarProps {\n  onSearchClick?: () => void\n  onActivityClick?: () => void\n  activityOpen?: boolean\n}\n\nexport function TitleBar({ onSearchClick, onActivityClick, activityOpen = false }: TitleBarProps) {\n  const [currentPlatform, setCurrentPlatform] = useState<Platform>('unknown')\n  const [isMobile, setIsMobile] = useState(true)\n  const pathname = usePathname()\n  const router = useRouter()\n  const { leftSidebarVisible, centerPanelVisible, rightSidebarVisible, toggleLeftSidebar, toggleCenterPanel, toggleRightSidebar } = useSidebarStore()\n  \n  // 检查关闭面板后是否会导致\"仅左\"状态或无面板状态\n  const wouldCauseLeftOnly = (currentVisible: boolean, panel: 'left' | 'center' | 'right') => {\n    // 如果面板本来就不可见，不会导致问题（打开面板总是允许的）\n    if (!currentVisible) return false\n    \n    const visibleCount = [leftSidebarVisible, centerPanelVisible, rightSidebarVisible].filter(Boolean).length\n    \n    if (visibleCount === 1) return true // 不允许关闭最后一个面板\n    \n    if (visibleCount === 2) {\n      // 只有当关闭中间或右侧面板会导致\"仅左\"状态时才阻止\n      if (panel === 'center' && leftSidebarVisible && !rightSidebarVisible) return true\n      if (panel === 'right' && leftSidebarVisible && !centerPanelVisible) return true\n      // 关闭左侧面板不会导致\"仅左\"状态（它会变成\"仅中\"或\"仅右\"），所以允许\n    }\n    \n    return false\n  }\n  const { recordToolbarConfig, setRecordToolbarConfig } = useSettingStore()\n  const { activeFilePath } = useArticleStore()\n  const { hasUpdate } = useUpdateStore()\n  const t = useTranslations()\n  const { isModifierPressed } = useToolbarShortcuts()\n\n  const getFileName = () => {\n    if (!activeFilePath) return ''\n    const parts = activeFilePath.split('/')\n    return parts[parts.length - 1]\n  }\n\n  const searchPlaceholder = getFileName() || t('navigation.searchPlaceholder')\n\n\n  // 拖拽传感器配置\n  const sensors = useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        delay: 200,\n        tolerance: 5,\n      },\n    })\n  )\n\n  // 处理拖拽结束\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event\n\n    if (over && active.id !== over.id) {\n      const oldIndex = recordToolbarConfig.findIndex((item) => item.id === active.id)\n      const newIndex = recordToolbarConfig.findIndex((item) => item.id === over.id)\n      \n      const newItems = arrayMove(recordToolbarConfig, oldIndex, newIndex)\n      const updatedItems = newItems.map((item, index) => ({\n        ...item,\n        order: index\n      }))\n      setRecordToolbarConfig(updatedItems)\n    }\n  }\n\n  useEffect(() => {\n    // 检查是否为移动设备\n    setIsMobile(isMobileDevice())\n    \n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch (error) {\n      console.error('Error detecting platform:', error)\n    }\n  }, [])\n\n\n\n  const handleMinimize = async () => {\n    try {\n      const window = getCurrentWindow()\n      await window.minimize()\n    } catch (error) {\n      console.error('Error minimizing window:', error)\n    }\n  }\n\n  const handleMaximize = async () => {\n    try {\n      const window = getCurrentWindow()\n      await window.toggleMaximize()\n    } catch (error) {\n      console.error('Error maximizing window:', error)\n    }\n  }\n\n  const handleClose = async () => {\n    try {\n      const window = getCurrentWindow()\n      await window.close()\n    } catch (error) {\n      console.error('Error closing window:', error)\n    }\n  }\n\n  // 移动端不显示标题栏\n  if (isMobile) {\n    return null\n  }\n\n  // 平台未知时不显示\n  if (currentPlatform === 'unknown') {\n    return null\n  }\n\n  // macOS: 红绿灯按钮在左侧，拖拽区域需要避开\n  // Windows/Linux: 控制按钮在右侧，拖拽区域需要避开\n  const isMacOS = currentPlatform === 'macos'\n\n  return (\n    <TooltipProvider>\n      <div\n        className=\"h-[36px] w-full flex flex-nowrap items-center select-none shrink-0 fixed top-0 left-0 right-0 z-[9999] border-b bg-background\"\n        style={{\n          // macOS 红绿灯按钮在左侧，需要留出空间（约 70px）\n          paddingLeft: isMacOS ? '70px' : '0',\n        }}\n        data-tauri-drag-region\n      >\n        {/* 左侧记录工具栏按钮 */}\n        <div id=\"onboarding-target-record-toolbar\" className=\"flex items-center gap-0.5 px-2 shrink-0\" data-tauri-drag-region=\"false\">\n          <TooltipProvider>\n            <DndContext\n              sensors={sensors}\n              collisionDetection={closestCenter}\n              onDragEnd={handleDragEnd}\n            >\n              <SortableContext\n                items={recordToolbarConfig.filter(item => item.enabled).map(item => item.id)}\n                strategy={horizontalListSortingStrategy}\n              >\n                <div className=\"flex\">\n                  {recordToolbarConfig\n                    .filter(item => item.enabled)\n                    .sort((a, b) => a.order - b.order)\n                    .map((item, index) => {\n                      const renderToolbarItem = () => {\n                        switch (item.id) {\n                          case 'text':\n                            return <ControlText />\n                          case 'recording':\n                            return <ControlRecording />\n                          case 'scan':\n                            return <ControlScan />\n                          case 'image':\n                            return <ControlImage />\n                          case 'link':\n                            return <ControlLink />\n                          case 'file':\n                            return <ControlFile />\n                          case 'todo':\n                            return <ControlTodo />\n                          default:\n                            return null\n                        }\n                      }\n                      \n                      return (\n                        <DraggableToolbarItem\n                          key={item.id}\n                          id={item.id}\n                          shortcutNumber={index + 1}\n                          showShortcut={isModifierPressed && index < 9}\n                        >\n                          {renderToolbarItem()}\n                        </DraggableToolbarItem>\n                      )\n                    })}\n                </div>\n              </SortableContext>\n            </DndContext>\n          </TooltipProvider>\n        </div>\n\n        {/* 中间搜索输入框 */}\n        <div className=\"flex-1 flex items-center justify-center px-4 min-w-[200px] max-w-[600px] mx-auto\" data-tauri-drag-region>\n          <div \n            className=\"relative w-full h-6 max-w-md group cursor-pointer flex justify-center items-center border rounded-sm\"\n            onClick={() => onSearchClick?.()}\n            data-tauri-drag-region=\"false\"\n          >\n            <Search className=\"size-3.5 text-muted-foreground\" />\n            <div className=\"pl-2 text-xs text-muted-foreground transition-colors\">\n              <span className=\"truncate\">{searchPlaceholder}</span>\n            </div>\n          </div>\n        </div>\n\n        {/* 右侧按钮 */}\n        <div className=\"flex items-center gap-0.5 px-2 shrink-0\" data-tauri-drag-region=\"false\">\n          {/* 左侧边栏切换按钮 */}\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={`h-8 w-8 ${wouldCauseLeftOnly(leftSidebarVisible, 'left') ? 'cursor-not-allowed opacity-50' : ''}`}\n                onClick={() => {\n                  if (!wouldCauseLeftOnly(leftSidebarVisible, 'left')) {\n                    toggleLeftSidebar()\n                  }\n                }}\n              >\n                <PanelLeft className={`h-4 w-4 ${!leftSidebarVisible ? 'opacity-30' : ''}`} />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>{leftSidebarVisible ? t('navigation.hideLeftSidebar') : t('navigation.showLeftSidebar')}</p>\n            </TooltipContent>\n          </Tooltip>\n\n          {/* 中间面板切换按钮 */}\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={`h-8 w-8 ${wouldCauseLeftOnly(centerPanelVisible, 'center') ? 'cursor-not-allowed opacity-50' : ''}`}\n                onClick={() => {\n                  if (!wouldCauseLeftOnly(centerPanelVisible, 'center')) {\n                    toggleCenterPanel()\n                  }\n                }}\n              >\n                <SquarePen className={`h-4 w-4 ${!centerPanelVisible ? 'opacity-30' : ''}`} />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>{centerPanelVisible ? t('navigation.hideCenterPanel') : t('navigation.showCenterPanel')}</p>\n            </TooltipContent>\n          </Tooltip>\n\n          {/* 右侧边栏切换按钮 */}\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={`h-8 w-8 ${wouldCauseLeftOnly(rightSidebarVisible, 'right') ? 'cursor-not-allowed opacity-50' : ''}`}\n                onClick={() => {\n                  if (!wouldCauseLeftOnly(rightSidebarVisible, 'right')) {\n                    toggleRightSidebar()\n                  }\n                }}\n              >\n                <PanelRight className={`h-4 w-4 ${!rightSidebarVisible ? 'opacity-30' : ''}`} />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>{rightSidebarVisible ? t('navigation.hideRightSidebar') : t('navigation.showRightSidebar')}</p>\n            </TooltipContent>\n          </Tooltip>\n          \n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={`h-8 w-8 ${activityOpen ? 'bg-primary/10 text-primary hover:bg-primary/15' : ''}`}\n                onClick={onActivityClick}\n              >\n                <CalendarDays className=\"h-4 w-4\" />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>{t('navigation.activity')}</p>\n            </TooltipContent>\n          </Tooltip>\n\n          <SyncToggle />\n          \n          <PinToggle />\n          \n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={`h-8 w-8 relative ${pathname.includes('/core/setting') ? 'bg-primary/50 hover:bg-primary/60' : ''}`}\n                onClick={() => {\n                  if (pathname.includes('/core/setting')) {\n                    router.push('/core/main')\n                  } else {\n                    router.push('/core/setting')\n                  }\n                }}\n              >\n                {pathname.includes('/core/setting') ? (\n                  <Cog className=\"h-4 w-4\" />\n                ) : (\n                  <Settings className=\"h-4 w-4\" />\n                )}\n                {hasUpdate && !pathname.includes('/core/setting') && (\n                  <span className=\"absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500\" />\n                )}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent side=\"bottom\">\n              <p>{pathname.includes('/core/setting') ? t('common.back') : t('common.settings')}</p>\n            </TooltipContent>\n          </Tooltip>\n          \n          <AppStatus />\n        </div>\n\n        {/* Windows 控制按钮 */}\n        {!isMacOS && (\n          <div className=\"flex items-center shrink-0 relative z-10\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-9 w-12 rounded-none hover:bg-accent\"\n              onClick={handleMinimize}\n            >\n              <Minus className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-9 w-12 rounded-none hover:bg-accent\"\n              onClick={handleMaximize}\n            >\n              <Square className=\"h-3.5 w-3.5\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"h-9 w-12 rounded-none hover:bg-destructive hover:text-destructive-foreground\"\n              onClick={handleClose}\n            >\n              <X className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        )}\n      </div>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/tooltip-button.tsx",
    "content": "import { Button } from \"@/components/ui/button\"\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\nexport function TooltipButton(\n  {\n    icon,\n    tooltipText, \n    onClick,\n    disabled = false,\n    variant = \"ghost\",\n    size = \"icon\",\n    side = \"top\",\n    buttonClassName,\n    buttonId,\n    ...props \n  }:\n  {\n    icon: React.ReactNode;\n    tooltipText: string;\n    onClick?: () => void;\n    disabled?: boolean;\n    variant?: \"default\" | \"destructive\" | \"outline\" | \"secondary\" | \"ghost\" | \"link\" | null | undefined;\n    size?: \"icon\" | \"sm\" | \"default\" | \"lg\";\n    side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n    buttonClassName?: string;\n    buttonId?: string;\n  })\n{\n  return (\n    <TooltipProvider>\n      <Tooltip {...props}>\n        <TooltipTrigger asChild>\n          <Button id={buttonId} className={cn(\"relative\", buttonClassName)} disabled={disabled} size={size} variant={variant} onClick={onClick}>\n            {icon}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent side={side}>\n          <p>{tooltipText}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/accordion.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n))\nAccordionItem.displayName = \"AccordionItem\"\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n))\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "src/components/ui/agent-plan.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  CheckCircle2,\n  Circle,\n  CircleAlert,\n  CircleDotDashed,\n  CircleX,\n  ChevronRight,\n  Brain,\n  Zap,\n  Eye,\n  Loader2,\n  Clock,\n  XCircle,\n  CheckCircle,\n  ChevronDown,\n  ChevronUp,\n} from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTranslations } from \"next-intl\";\nimport { DiffViewer } from \"@/components/ui/diff-viewer\";\nimport { formatConfirmationPreview } from \"@/lib/agent/tool-confirmation-display\";\n\n// Type definitions from existing codebase\ninterface ToolCall {\n  id: string;\n  toolName: string;\n  params: Record<string, any>;\n  result?: {\n    success: boolean;\n    message?: string;\n    data?: any;\n    error?: string;\n  };\n  status: \"pending\" | \"running\" | \"success\" | \"error\";\n  timestamp: number;\n}\n\ninterface ConfirmationRecord {\n  toolName: string;\n  params: Record<string, any>;\n  status: \"pending\" | \"confirmed\" | \"cancelled\";\n  timestamp: number;\n  scope?: \"once\" | \"conversation\";\n  sessionApprovalType?: \"write\" | \"runtime-script-skill\";\n  sessionApprovalSkillId?: string;\n}\n\ninterface ReActStep {\n  thought: string;\n  action?: {\n    tool: string;\n    params: Record<string, any>;\n  };\n  observation?: string;\n  duration?: number;\n}\n\n// Props for the unified AgentPlan component\ninterface AgentPlanProps {\n  // Mode: 'live' for real-time execution, 'history' for saved history\n  mode: \"live\" | \"history\";\n\n  // Props for live mode\n  isRunning?: boolean;\n  isThinking?: boolean;\n  currentThought?: string;\n  thoughtHistory?: string[];\n  completedSteps?: ReActStep[]; // 已完成的完整步骤\n  currentAction?: string;\n  currentObservation?: string;\n  toolCalls?: ToolCall[];\n  pendingConfirmation?: {\n    toolName: string;\n    params: Record<string, any>;\n    originalContent?: string;\n    modifiedContent?: string;\n    filePath?: string;\n    canApproveForSession?: boolean;\n    sessionApprovalType?: \"write\" | \"runtime-script-skill\";\n    sessionApprovalSkillId?: string;\n  };\n  confirmationHistory?: ConfirmationRecord[];\n  currentStepStartTime?: number; // 当前步骤开始时间戳\n\n  // Props for history mode\n  historyJson?: string;\n\n  // Callbacks for live mode\n  onConfirm?: (scope?: \"once\" | \"conversation\") => void;\n  onCancel?: () => void;\n\n  // i18n namespace (optional, defaults to 'record.chat.input.agent')\n  i18nNs?: string;\n\n  // Embedded mode: render without outer container (for use in combined panels)\n  embedded?: boolean;\n}\n\n// Internal step representation for unified display\ninterface DisplayStep {\n  id: string;\n  thought: string;\n  action?: {\n    tool: string;\n    params: Record<string, any>;\n  };\n  observation?: string;\n  status: \"completed\" | \"in-progress\" | \"pending\" | \"need-help\" | \"failed\";\n  confirmation?: ConfirmationRecord;\n  tools?: string[];\n  duration?: number;  // 耗时（毫秒）\n}\n\nexport function AgentPlan({\n  mode,\n  isRunning = false,\n  isThinking = false,\n  currentThought = \"\",\n  thoughtHistory = [],\n  completedSteps = [],\n  currentAction = \"\",\n  currentObservation = \"\",\n  toolCalls = [],\n  pendingConfirmation,\n  confirmationHistory = [],\n  currentStepStartTime,\n  historyJson,\n  onConfirm,\n  onCancel,\n  i18nNs = \"record.chat.input.agent\",\n  embedded = false,\n}: AgentPlanProps) {\n  const t = useTranslations(i18nNs);\n  const rootT = useTranslations();\n  const [expandedTasks, setExpandedTasks] = React.useState<string[]>([]);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const thoughtRefs = React.useRef<Map<string, HTMLParagraphElement>>(new Map());\n  const [currentStepDuration, setCurrentStepDuration] = React.useState<number>(0);\n  const [showDiff, setShowDiff] = React.useState(true);\n  const [autoScrollEnabled, setAutoScrollEnabled] = React.useState(true);\n\n  const scrollStepIntoView = React.useCallback((stepId: string) => {\n    if (embedded) return;\n\n    setTimeout(() => {\n      const el = document.getElementById(`step-${stepId}`);\n      if (el) {\n        el.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n      }\n    }, 50);\n  }, [embedded]);\n\n  const extractFinalAnswer = React.useCallback((content: string): string => {\n    if (!content) return \"\";\n\n    const normalized = content.replace(/Action:\\s*Final\\s*Answer:\\s*/i, \"Final Answer: \");\n    const finalAnswerPatterns = [\n      /Final Answer[:：]\\s*([\\s\\S]*)/i,\n      /最终答案[:：]?\\s*([\\s\\S]*)/i,\n    ];\n\n    for (const pattern of finalAnswerPatterns) {\n      const match = normalized.match(pattern);\n      if (match?.[1]) {\n        return match[1].trim();\n      }\n    }\n\n    return \"\";\n  }, []);\n\n  const getThoughtBody = React.useCallback((content: string): string => {\n    if (!content) return \"\";\n\n    return content\n      .replace(/^Thought:\\s*/i, \"\")\n      .replace(/^思考[:：]?\\s*/i, \"\")\n      .trim();\n  }, []);\n\n  const shouldHideThoughtBlock = React.useCallback((thought?: string): boolean => {\n    if (!thought) return false;\n\n    const finalAnswer = extractFinalAnswer(thought);\n    if (!finalAnswer) return false;\n\n    const thoughtBody = getThoughtBody(thought)\n      .replace(/Final Answer[:：][\\s\\S]*/i, \"\")\n      .replace(/最终答案[:：]?[\\s\\S]*/i, \"\")\n      .trim();\n\n    if (!thoughtBody) {\n      return true;\n    }\n\n    const normalizeForCompare = (value: string) =>\n      value\n        .toLowerCase()\n        .replace(/\\s+/g, \"\")\n        .replace(/[：:，。、“”\"'`]/g, \"\");\n\n    const normalizedThought = normalizeForCompare(thoughtBody);\n    const normalizedAnswer = normalizeForCompare(finalAnswer);\n\n    return !normalizedThought || normalizedAnswer.includes(normalizedThought);\n  }, [extractFinalAnswer, getThoughtBody]);\n\n  // 实时更新当前步骤的耗时\n  React.useEffect(() => {\n    if (mode === \"live\" && isRunning && currentStepStartTime) {\n      // 立即更新一次\n      setCurrentStepDuration(Date.now() - currentStepStartTime);\n\n      // 设置定时器，每 100ms 更新一次\n      const interval = setInterval(() => {\n        setCurrentStepDuration(Date.now() - currentStepStartTime);\n      }, 100);\n\n      return () => clearInterval(interval);\n    } else {\n      setCurrentStepDuration(0);\n    }\n  }, [mode, isRunning, currentStepStartTime]);\n\n  // Parse history JSON in history mode\n  const parseHistory = (): DisplayStep[] => {\n    if (mode === \"live\") {\n      return [];\n    }\n\n    try {\n      const history = JSON.parse(historyJson || \"\");\n\n      // Handle new format with steps\n      if (history.steps && history.steps.length > 0) {\n        return history.steps.map((step: ReActStep, index: number) => {\n          const toolCall = history.toolCalls?.[index];\n          let status: DisplayStep[\"status\"] = \"completed\";\n\n          // 优先使用 toolCall 的实际执行状态，而不是通过文本匹配判断\n          if (toolCall?.result?.success !== undefined) {\n            status = toolCall.result.success ? \"completed\" : \"failed\";\n          } else if (toolCall?.status) {\n            switch (toolCall.status) {\n              case \"success\":\n                status = \"completed\";\n                break;\n              case \"error\":\n                status = \"failed\";\n                break;\n              default:\n                // 回退到文本匹配判断\n                if (step.observation) {\n                  status =\n                    step.observation.includes(\"失败\") ||\n                    step.observation.includes(\"错误\")\n                      ? \"failed\"\n                      : \"completed\";\n                }\n            }\n          } else if (step.observation) {\n            status =\n              step.observation.includes(\"失败\") ||\n              step.observation.includes(\"错误\")\n                ? \"failed\"\n                : \"completed\";\n          } else if (!step.action) {\n            // 只有思考没有动作和观察，说明是未完成的步骤\n            status = \"pending\";\n          }\n\n          return {\n            id: `history-${index}`,\n            thought: step.thought,\n            action: step.action,\n            observation: step.observation,\n            status,\n            duration: step.duration,\n            tools: toolCall ? [toolCall.toolName] : undefined,\n          };\n        });\n      }\n\n      // Handle old format with thought field\n      if (history.thought) {\n        const thoughts = history.thought.split(\"\\n\\n\").filter((t: string) => t.trim());\n        return thoughts.map((thought: string, index: number) => ({\n          id: `history-${index}`,\n          thought,\n          status: \"completed\" as const,\n        }));\n      }\n\n      return [];\n    } catch {\n      return [];\n    }\n  };\n\n  // Convert live mode data to DisplayStep format\n  const convertLiveData = (): DisplayStep[] => {\n    const steps: DisplayStep[] = [];\n\n    // 优先使用 completedSteps（包含完整的步骤信息）\n    if (completedSteps && completedSteps.length > 0) {\n      // 跟踪已使用的 toolCalls 索引，避免重复匹配\n      const usedToolCallIndices = new Set<number>();\n\n      completedSteps.forEach((step, index) => {\n        const confirmation = confirmationHistory[index];\n        let status: DisplayStep[\"status\"] = \"completed\";\n\n        // 通过工具名称匹配 toolCall（而不是索引匹配）\n        // 因为 completedSteps 和 toolCalls 的数量可能不一致\n        let toolCall: ToolCall | undefined = undefined;\n        if (step.action) {\n          // 从后往前查找，优先使用最新的未使用的 toolCall\n          for (let i = toolCalls.length - 1; i >= 0; i--) {\n            if (!usedToolCallIndices.has(i) && toolCalls[i].toolName === step.action.tool) {\n              toolCall = toolCalls[i];\n              usedToolCallIndices.add(i);\n              break;\n            }\n          }\n        }\n\n        // 优先使用 toolCall 的实际执行状态，而不是通过文本匹配判断\n        if (toolCall) {\n          switch (toolCall.status) {\n            case \"success\":\n              status = \"completed\";\n              break;\n            case \"error\":\n              status = \"failed\";\n              break;\n            case \"running\":\n              status = \"in-progress\";\n              break;\n            case \"pending\":\n              status = \"pending\";\n              break;\n            default:\n              // 如果 toolCall.status 无效，回退到文本匹配判断\n              if (step.observation) {\n                status =\n                  step.observation.includes(\"失败\") ||\n                  step.observation.includes(\"错误\")\n                    ? \"failed\"\n                    : \"completed\";\n              } else if (!step.action) {\n                status = \"pending\";\n              }\n          }\n        } else if (step.observation) {\n          // 如果没有对应的 toolCall，回退到文本匹配判断\n          status =\n            step.observation.includes(\"失败\") ||\n            step.observation.includes(\"错误\")\n              ? \"failed\"\n              : \"completed\";\n        } else if (!step.action) {\n          status = \"pending\";\n        }\n\n        steps.push({\n          id: `completed-${index}`,\n          thought: step.thought,\n          action: step.action,\n          observation: step.observation,\n          status,\n          duration: step.duration,\n          confirmation,\n        });\n      });\n    } else {\n      // 兼容旧的 thoughtHistory 格式\n      thoughtHistory.forEach((thought, index) => {\n        const confirmation = confirmationHistory[index];\n        let status: DisplayStep[\"status\"] = \"completed\";\n\n        if (confirmation) {\n          status =\n            confirmation.status === \"confirmed\" ? \"completed\" : \"failed\";\n        }\n\n        steps.push({\n          id: `thought-history-${index}`,\n          thought,\n          status,\n          confirmation,\n        });\n      });\n    }\n\n    // Add current step\n    if (currentThought || currentAction || currentObservation) {\n      let status: DisplayStep[\"status\"] = \"in-progress\";\n\n      if (pendingConfirmation) {\n        status = \"need-help\";\n      } else if (currentObservation) {\n        status = \"completed\";\n      } else if (isThinking && !currentThought) {\n        // 正在等待 AI 生成思考，显示为 pending 状态（会有 loading 效果）\n        status = \"pending\";\n      }\n\n      const currentStep: DisplayStep = {\n        id: \"current\",\n        thought: currentThought || \"\",\n        status,\n        duration: currentStepDuration, // 使用实时计算的耗时\n      };\n\n      if (currentAction) {\n        // Try to parse action as \"toolName(params)\" format\n        const match = currentAction.match(/^(\\w+)\\((.*)\\)$/);\n        if (match) {\n          currentStep.action = {\n            tool: match[1],\n            params: match[2] ? JSON.parse(match[2]) : {},\n          };\n        }\n      }\n\n      if (currentObservation) {\n        currentStep.observation = currentObservation;\n      }\n\n      if (toolCalls.length > 0) {\n        currentStep.tools = toolCalls.map((tc) => tc.toolName);\n      }\n\n      steps.push(currentStep);\n    }\n\n    // 如果正在思考但没有当前步骤内容，添加一个 loading 步骤\n    if (isThinking && !currentThought && !currentAction && !currentObservation) {\n      steps.push({\n        id: \"thinking-placeholder\",\n        thought: \"\",\n        status: \"pending\",\n        duration: currentStepDuration, // 使用实时计算的耗时\n      });\n    }\n\n    return steps;\n  };\n\n  const displaySteps: DisplayStep[] =\n    mode === \"live\" ? convertLiveData() : parseHistory();\n\n  // Auto-scroll to bottom when content changes in live mode\n  React.useEffect(() => {\n    if (mode === \"live\" && (currentThought || currentObservation) && contentRef.current && autoScrollEnabled) {\n      contentRef.current.scrollTop = contentRef.current.scrollHeight;\n    }\n  }, [currentThought, currentObservation, currentStepDuration, mode, autoScrollEnabled]);\n\n  // Handle scroll to detect if user manually scrolled up\n  const handleScroll = React.useCallback(() => {\n    if (!contentRef.current) return;\n    const { scrollTop, scrollHeight, clientHeight } = contentRef.current;\n    const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;\n    setAutoScrollEnabled(isAtBottom);\n  }, []);\n\n  // Auto-scroll thought paragraph to bottom when content updates\n  React.useEffect(() => {\n    if (mode === \"live\" && currentThought) {\n      const currentStepEl = thoughtRefs.current.get(\"current\");\n      if (currentStepEl && autoScrollEnabled) {\n        currentStepEl.scrollTop = currentStepEl.scrollHeight;\n      }\n    }\n  }, [currentThought, mode, autoScrollEnabled]);\n\n  // Auto-expand current step in live mode - keep current step always expanded while running\n  React.useEffect(() => {\n    if (mode === \"live\" && displaySteps.length > 0 && isRunning) {\n      const currentStepId = displaySteps[displaySteps.length - 1]?.id;\n      if (currentStepId && !expandedTasks.includes(currentStepId)) {\n        setExpandedTasks((prev) => {\n          const newState = [...prev, currentStepId];\n          // 非嵌入模式下自动展开后滚动到该步骤\n          scrollStepIntoView(currentStepId);\n          return newState;\n        });\n      }\n    }\n  }, [displaySteps.length, currentThought, currentObservation, isRunning, mode, expandedTasks, scrollStepIntoView]);\n\n  const confirmationPreview = React.useMemo(() => {\n    if (!pendingConfirmation) {\n      return null;\n    }\n\n    return formatConfirmationPreview(\n      pendingConfirmation.toolName,\n      pendingConfirmation.params ?? {}\n    );\n  }, [pendingConfirmation]);\n\n  const translateKey = React.useCallback((key: string, fallback: string) => {\n    return rootT.has(key) ? rootT(key) : fallback;\n  }, [rootT]);\n\n  const formatFieldValue = React.useCallback((value: unknown) => {\n    if (typeof value === \"string\") {\n      return value;\n    }\n\n    if (\n      typeof value === \"number\" ||\n      typeof value === \"boolean\" ||\n      value === null ||\n      value === undefined\n    ) {\n      return String(value);\n    }\n\n    try {\n      return JSON.stringify(value, null, 2);\n    } catch {\n      return String(value);\n    }\n  }, []);\n\n  // Don't render if no content in history mode\n  if (mode === \"history\" && displaySteps.length === 0) {\n    return null;\n  }\n\n  // Don't render if not running in live mode (unless there's content)\n  if (mode === \"live\" && !isRunning && displaySteps.length === 0) {\n    return null;\n  }\n\n  // Toggle step expansion\n  const toggleStepExpansion = (stepId: string) => {\n    // In live mode, prevent collapsing the current (in-progress) step\n    if (mode === \"live\" && isRunning) {\n      const currentStepId = displaySteps[displaySteps.length - 1]?.id;\n      if (stepId === currentStepId) {\n        // Don't allow collapsing the current step - keep it expanded\n        return;\n      }\n    }\n    setExpandedTasks((prev) => {\n      const isExpanding = !prev.includes(stepId);\n      if (isExpanding) {\n        // 非嵌入模式下展开时滚动到该步骤\n        scrollStepIntoView(stepId);\n      }\n      return prev.includes(stepId)\n        ? prev.filter((id) => id !== stepId)\n        : [...prev, stepId];\n    });\n  };\n\n  // Handle confirmation\n  const handleConfirm = (scope: \"once\" | \"conversation\" = \"once\") => {\n    if (onConfirm) onConfirm(scope);\n  };\n\n  const handleCancel = () => {\n    if (onCancel) onCancel();\n  };\n\n  // Clean markdown syntax from text\n  const cleanMarkdown = (text: string): string => {\n    return text\n      // Remove bold/italic markers\n      .replace(/\\*\\*\\*/g, '')\n      .replace(/\\*\\*/g, '')\n      .replace(/\\*/g, '')\n      .replace(/___/g, '')\n      .replace(/__/g, '')\n      .replace(/_/g, '')\n      // Remove headers\n      .replace(/^#{1,6}\\s+/gm, '')\n      // Remove strikethrough\n      .replace(/~~/g, '')\n      // Remove code blocks and inline code markers\n      .replace(/```/g, '')\n      .replace(/`/g, '')\n      // Remove links but keep text\n      .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1')\n      // Remove blockquotes\n      .replace(/^>\\s+/gm, '')\n      // Remove horizontal rules\n      .replace(/^[-*_]{3,}\\s*$/gm, '')\n      // Clean up extra whitespace\n      .replace(/\\s+/g, ' ')\n      .trim();\n  };\n\n  // Extract title from step content (prioritize observation result, then action, then thought)\n  const extractTitle = (step: DisplayStep): string => {\n    // 特殊处理 loading 占位符\n    if (step.id === \"thinking-placeholder\" || (!step.thought && !step.action && !step.observation)) {\n      return t(\"thinking\");\n    }\n\n    // Helper to extract meaningful text from content\n    const extractFromContent = (content: string): string => {\n      if (!content || !content.trim()) return '';\n\n      const finalAnswer = extractFinalAnswer(content);\n      if (finalAnswer) {\n        return extractFromContent(finalAnswer);\n      }\n\n      // 预处理：移除首尾的代码块标记 ``` 及其周围的空白行\n      let processedContent = content.trim();\n\n      // 移除所有 ``` 标记及其所在行\n      const lines = processedContent.split('\\n');\n      const filteredLines = lines.filter(line => {\n        const trimmed = line.trim();\n        // 跳过 ``` 行（不管是否有语言标识符）\n        if (trimmed === '```' || trimmed.startsWith('```')) {\n          return false;\n        }\n        return true;\n      });\n      processedContent = filteredLines.join('\\n').trim();\n\n      // 按行分割并过滤空行\n      const contentLines = processedContent.split(\"\\n\").map(l => l.trim()).filter(l => l);\n\n      // 尝试从第一行获取\n      for (let i = 0; i < Math.min(contentLines.length, 5); i++) {\n        const line = contentLines[i];\n\n        if (!line) continue;\n\n        // 如果是标题（## 开头），保留标题格式，移除 # 标记\n        const headerMatch = line.match(/^(#{1,6})\\s+(.+)$/);\n        if (headerMatch) {\n          const titleText = headerMatch[2].trim();\n          if (titleText) {\n            return titleText.length > 50 ? titleText.substring(0, 50) + \"...\" : titleText;\n          }\n        }\n\n        const cleaned = cleanMarkdown(line);\n        if (cleaned.length > 0) {\n          return cleaned.length > 50 ? cleaned.substring(0, 50) + \"...\" : cleaned;\n        }\n      }\n\n      // 如果都没找到，返回第一行有效内容\n      const firstValidLine = contentLines.find(l => l && l.length > 0);\n      return firstValidLine || '';\n    };\n\n    // Use observation first - this contains the actual result of tool execution\n    if (step.observation && step.observation.trim()) {\n      const title = extractFromContent(step.observation);\n      if (title) return title;\n    }\n\n    // Use action if available\n    if (step.action) {\n      const actionText = `${step.action.tool}(...)`;\n      if (actionText.length > 50) {\n        return actionText.substring(0, 50) + \"...\";\n      }\n      return actionText;\n    }\n\n    // Use thought if available\n    if (step.thought && step.thought.trim()) {\n      const title = extractFromContent(step.thought);\n      if (title) return title;\n    }\n\n    return t(\"thinking\");\n  };\n\n  // Get status icon\n  const getStatusIcon = (status: DisplayStep[\"status\"]) => {\n    switch (status) {\n      case \"completed\":\n        return <CheckCircle2 className=\"h-4.5 w-4.5 text-green-500\" />;\n      case \"in-progress\":\n        return <CircleDotDashed className=\"h-4.5 w-4.5 text-blue-500\" />;\n      case \"need-help\":\n        return <CircleAlert className=\"h-4.5 w-4.5 text-yellow-500\" />;\n      case \"failed\":\n        return <CircleX className=\"h-4.5 w-4.5 text-red-500\" />;\n      case \"pending\":\n        return <Loader2 className=\"h-4.5 w-4.5 text-blue-500 animate-spin\" />;\n      default:\n        return <Circle className=\"h-4.5 w-4.5 text-muted-foreground\" />;\n    }\n  };\n\n  // 格式化耗时显示\n  const formatDuration = (duration?: number): string => {\n    if (duration === undefined || duration === null) return \"\";\n    if (duration < 1000) return `${duration}ms`;\n    if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`;\n    const minutes = Math.floor(duration / 60000);\n    const seconds = ((duration % 60000) / 1000).toFixed(0);\n    return `${minutes}m ${seconds}s`;\n  };\n\n  // 渲染步骤列表内容（用于 embedded 和非 embedded 模式）\n  const renderSteps = () => (\n    <>\n      {displaySteps.map((step, index) => {\n        const isLastStep = index === displaySteps.length - 1;\n        // In live mode, current (last) step is always expanded\n        const isExpanded = mode === \"live\" && isRunning && isLastStep\n          ? true\n          : expandedTasks.includes(step.id);\n        const isCompleted = step.status === \"completed\";\n        const isCurrentStep = mode === \"live\" && isRunning && isLastStep;\n        const canToggle = !isCurrentStep; // Current step cannot be toggled in live mode\n\n        return (\n          <li\n            key={step.id}\n            id={`step-${step.id}`}\n            className={`${index !== 0 ? \"mt-1 pt-2\" : \"\"}`}\n          >\n            {/* Step row */}\n            <div className=\"group flex items-center gap-2 py-1\">\n              <div\n                className={`shrink-0 ${canToggle ? \"cursor-pointer\" : \"\"}`}\n                onClick={() => canToggle && toggleStepExpansion(step.id)}\n              >\n                <div className={canToggle ? \"cursor-pointer\" : \"\"}>\n                  {getStatusIcon(step.status)}\n                </div>\n              </div>\n\n              <div\n                className={`flex min-w-0 grow ${canToggle ? \"cursor-pointer\" : \"\"} items-center justify-between`}\n                onClick={() => canToggle && toggleStepExpansion(step.id)}\n              >\n                <div className=\"flex-1 truncate\">\n                  <span\n                    className={`${\n                      isCompleted ? \"text-muted-foreground\" : \"\"\n                    }`}\n                  >\n                    {extractTitle(step)}\n                  </span>\n                </div>\n\n                <div className=\"flex shrink-0 items-center gap-2\">\n                  {/* 耗时显示 */}\n                  {step.duration !== undefined && (\n                    <span className=\"text-xs text-muted-foreground tabular-nums\">\n                      {formatDuration(step.duration)}\n                    </span>\n                  )}\n                  {canToggle && (\n                    <ChevronRight\n                      className={`size-4 text-muted-foreground shrink-0 transition-transform ${\n                        isExpanded ? \"rotate-90\" : \"\"\n                      }`}\n                    />\n                  )}\n                </div>\n              </div>\n            </div>\n\n            {/* Expanded details */}\n            {isExpanded && (\n              <div className=\"border-muted mt-1 mr-2 mb-1.5 ml-6 space-y-2\">\n                {/* Thought */}\n                {step.thought && !shouldHideThoughtBlock(step.thought) && (\n                  <div className=\"text-muted-foreground border-foreground/20 border-l border-dashed pl-3 text-xs\">\n                    <div className=\"flex items-center gap-2 py-1\">\n                      <Brain className=\"size-3.5 text-blue-500 shrink-0\" />\n                      <span className=\"font-medium text-xs\">\n                        {t(\"thought\")}\n                      </span>\n                    </div>\n                    <p\n                      ref={(el) => {\n                        if (step.id) {\n                          if (el) thoughtRefs.current.set(step.id, el);\n                          else thoughtRefs.current.delete(step.id);\n                        }\n                      }}\n                      className=\"whitespace-pre-wrap max-h-40 overflow-y-auto wrap-break-word py-1\"\n                    >\n                      {step.thought}\n                    </p>\n                  </div>\n                )}\n\n                {/* Action */}\n                {step.action && (\n                  <div className=\"text-muted-foreground border-foreground/20 border-l border-dashed pl-3 text-xs\">\n                    <div className=\"flex items-center gap-2 py-1\">\n                      <Zap className=\"size-3.5 text-yellow-500 shrink-0\" />\n                      <span className=\"font-medium text-xs\">\n                        {t(\"action\")}\n                      </span>\n                    </div>\n                    <div className=\"text-xs font-mono truncate\" title={JSON.stringify(step.action.params)}>\n                      {step.action.tool}\n                      {Object.keys(step.action.params).length > 0 ? '(...)' : '()'}\n                    </div>\n                  </div>\n                )}\n\n                {/* Observation */}\n                {step.observation && (\n                  <div className=\"text-muted-foreground border-foreground/20 border-l border-dashed pl-3 text-xs\">\n                    <div className=\"flex items-center gap-2 py-1\">\n                      <Eye className=\"size-3.5 text-green-500 shrink-0\" />\n                      <span className=\"font-medium text-xs\">\n                        {t(\"observation\")}\n                      </span>\n                    </div>\n                    <p className=\"whitespace-pre-wrap wrap-break-word py-1\">\n                      {step.observation}\n                    </p>\n                  </div>\n                )}\n\n                {/* Confirmation record */}\n                {step.confirmation && (\n                  <div className=\"flex items-center gap-2 py-1.5 px-3 border-t\">\n                    {step.confirmation.status === \"confirmed\" ? (\n                      <CheckCircle className=\"size-4 text-green-500 shrink-0\" />\n                    ) : (\n                      <XCircle className=\"size-4 text-red-500 shrink-0\" />\n                    )}\n                    <code className=\"text-sm text-muted-foreground flex-1 wrap-break-word font-mono\">\n                      {step.confirmation.toolName}\n                    </code>\n                  </div>\n                )}\n\n              </div>\n            )}\n          </li>\n        );\n      })}\n\n      {/* Current step confirmation (live mode only) */}\n      {mode === \"live\" && pendingConfirmation && (\n        <li className=\"mt-1 pt-2\">\n          <div className=\"rounded-md border border-border/50 bg-muted/30 overflow-hidden\">\n            {/* Confirmation header */}\n            <div className=\"flex items-center justify-between px-3 py-1.5\">\n              <div className=\"flex items-center gap-2 min-w-0\">\n                <Clock className=\"size-4.5 text-orange-500 shrink-0 animate-pulse\" />\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-sm text-foreground font-medium truncate\">\n                      {confirmationPreview\n                        ? translateKey(\n                            confirmationPreview.titleKey,\n                            pendingConfirmation.toolName\n                          )\n                        : pendingConfirmation.toolName}\n                    </span>\n                    {pendingConfirmation.filePath && (\n                      <span className=\"text-xs text-muted-foreground truncate\">\n                        {pendingConfirmation.filePath}\n                      </span>\n                    )}\n                  </div>\n                  {confirmationPreview && (\n                    <div className=\"text-xs text-muted-foreground mt-1 truncate\">\n                      {translateKey(\n                        confirmationPreview.descriptionKey,\n                        t(\"confirmation.description\")\n                      )}\n                    </div>\n                  )}\n                </div>\n              </div>\n              <div className=\"flex items-center gap-1 shrink-0\">\n                {/* Show diff button */}\n                {pendingConfirmation.originalContent && pendingConfirmation.modifiedContent && (\n                  <Button\n                    size=\"sm\"\n                    variant=\"ghost\"\n                    className=\"h-6 px-2 text-xs\"\n                    onClick={() => setShowDiff(!showDiff)}\n                  >\n                    {showDiff ? (\n                      <ChevronUp className=\"size-4\" />\n                    ) : (\n                      <ChevronDown className=\"size-4\" />\n                    )}\n                    <span className=\"ml-1\">Diff</span>\n                  </Button>\n                )}\n              </div>\n            </div>\n\n            {/* Diff view */}\n            {showDiff && pendingConfirmation.originalContent && pendingConfirmation.modifiedContent && (\n              <div className=\"border-t border-border/50\">\n                <DiffViewer\n                  original={pendingConfirmation.originalContent}\n                  modified={pendingConfirmation.modifiedContent}\n                  mode=\"lines\"\n                  showLineNumbers={true}\n                  maxHeight={200}\n                  className=\"border-0 rounded-none\"\n                />\n              </div>\n            )}\n\n            {!pendingConfirmation.originalContent &&\n              !pendingConfirmation.modifiedContent &&\n              confirmationPreview &&\n              confirmationPreview.fields.length > 0 && (\n                <div className=\"border-t border-border/50 px-3 py-2 space-y-2\">\n                  {confirmationPreview.fields.map((field) => {\n                    const label = translateKey(field.labelKey, field.name);\n                    const formattedValue = formatFieldValue(field.value);\n\n                    return (\n                      <div key={field.name} className=\"space-y-1\">\n                        <div className=\"text-xs font-medium text-muted-foreground\">\n                          {label}\n                        </div>\n                        {field.displayType === \"content\" ? (\n                          <pre className=\"max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/40 px-2 py-1 text-xs text-foreground\">\n                            {formattedValue}\n                          </pre>\n                        ) : (\n                          <div className=\"whitespace-pre-wrap break-words text-xs text-foreground\">\n                            {formattedValue}\n                          </div>\n                        )}\n                      </div>\n                    );\n                  })}\n                </div>\n              )}\n\n            {/* Confirmation buttons */}\n            <div className=\"flex items-center justify-end gap-1 px-3 py-1.5 border-t border-border/50\">\n              <Button\n                size=\"sm\"\n                variant=\"ghost\"\n                className=\"h-6 w-6 p-0\"\n                onClick={handleCancel}\n              >\n                <XCircle className=\"size-4 text-red-500\" />\n              </Button>\n              <Button\n                size=\"sm\"\n                variant=\"ghost\"\n                className=\"h-6 px-2 text-xs\"\n                onClick={() => handleConfirm(\"once\")}\n              >\n                <CheckCircle className=\"size-4 text-green-500\" />\n                <span className=\"ml-1\">允许这次</span>\n              </Button>\n              {pendingConfirmation.canApproveForSession && (\n                <Button\n                  size=\"sm\"\n                  variant=\"ghost\"\n                  className=\"h-6 px-2 text-xs\"\n                  onClick={() => handleConfirm(\"conversation\")}\n                >\n                  <CheckCircle2 className=\"size-4 text-green-600\" />\n                  <span className=\"ml-1\">\n                    {pendingConfirmation.sessionApprovalType === \"runtime-script-skill\"\n                      ? \"本会话允许此 Skill 脚本\"\n                      : \"本会话都允许\"}\n                  </span>\n                </Button>\n              )}\n            </div>\n          </div>\n        </li>\n      )}\n    </>\n  );\n\n  // Show loading state in live mode\n  if (mode === \"live\" && isRunning && displaySteps.length === 0) {\n    return (\n      <div className=\"w-full mb-4\">\n        {/* Loading 状态 */}\n        <div className=\"flex flex-col items-center justify-center py-8 space-y-4\">\n          {/* 旋转的 loading 图标 */}\n          <div className=\"relative\">\n            <div className=\"absolute inset-0 rounded-full border-2 border-border/30\" />\n            <Loader2 className=\"size-8 animate-spin text-blue-500\" />\n          </div>\n\n          {/* 状态文字 */}\n          <div className=\"text-center space-y-1\">\n            <p className=\"text-sm font-medium text-foreground\">\n              {isThinking ? t(\"thinking\") : t(\"running\")}\n            </p>\n            <p className=\"text-xs text-muted-foreground\">\n              {t(\"analyzingRequest\")}\n            </p>\n          </div>\n\n          {/* 脉冲动画点 */}\n          <div className=\"flex items-center gap-1.5\">\n            <div className=\"size-2 rounded-full bg-blue-500/60 animate-pulse [animation-delay:0ms]\" />\n            <div className=\"size-2 rounded-full bg-blue-500/60 animate-pulse [animation-delay:150ms]\" />\n            <div className=\"size-2 rounded-full bg-blue-500/60 animate-pulse [animation-delay:300ms]\" />\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  // Embedded 模式：只返回 <li> 元素\n  if (embedded) {\n    return <>{renderSteps()}</>\n  }\n\n  // 标准模式：返回完整的容器\n  return (\n    <div className=\"w-full mb-4\">\n      {/* 步骤列表 */}\n      <div className=\"overflow-hidden\" ref={contentRef} onScroll={handleScroll}>\n        <ul className=\"space-y-1\">\n          {renderSteps()}\n        </ul>\n      </div>\n    </div>\n  );\n}\n\nexport default AgentPlan;\n"
  },
  {
    "path": "src/components/ui/alert-dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nconst AlertDialog = AlertDialogPrimitive.Root\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n))\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName\n\nconst AlertDialogContent = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPortal>\n    <AlertDialogOverlay />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    />\n  </AlertDialogPortal>\n))\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName\n\nconst AlertDialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nAlertDialogHeader.displayName = \"AlertDialogHeader\"\n\nconst AlertDialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nAlertDialogFooter.displayName = \"AlertDialogFooter\"\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold\", className)}\n    {...props}\n  />\n))\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nAlertDialogDescription.displayName =\n  AlertDialogPrimitive.Description.displayName\n\nconst AlertDialogAction = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action\n    ref={ref}\n    className={cn(buttonVariants(), className)}\n    {...props}\n  />\n))\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(\n      buttonVariants({ variant: \"outline\" }),\n      \"mt-2 sm:mt-0\",\n      className\n    )}\n    {...props}\n  />\n))\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "src/components/ui/alert.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n))\nAlert.displayName = \"Alert\"\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nAlertTitle.displayName = \"AlertTitle\"\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n))\nAlertDescription.displayName = \"AlertDescription\"\n\nexport { Alert, AlertTitle, AlertDescription }\n"
  },
  {
    "path": "src/components/ui/avatar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Avatar = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full\",\n      className\n    )}\n    {...props}\n  />\n))\nAvatar.displayName = AvatarPrimitive.Root.displayName\n\nconst AvatarImage = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Image>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Image\n    ref={ref}\n    className={cn(\"aspect-square h-full w-full\", className)}\n    {...props}\n  />\n))\nAvatarImage.displayName = AvatarPrimitive.Image.displayName\n\nconst AvatarFallback = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Fallback>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Fallback\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full items-center justify-center rounded-full bg-muted\",\n      className\n    )}\n    {...props}\n  />\n))\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName\n\nexport { Avatar, AvatarImage, AvatarFallback }\n"
  },
  {
    "path": "src/components/ui/badge.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "src/components/ui/breadcrumb.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Breadcrumb = React.forwardRef<\n  HTMLElement,\n  React.ComponentPropsWithoutRef<\"nav\"> & {\n    separator?: React.ReactNode\n  }\n>(({ ...props }, ref) => <nav ref={ref} aria-label=\"breadcrumb\" {...props} />)\nBreadcrumb.displayName = \"Breadcrumb\"\n\nconst BreadcrumbList = React.forwardRef<\n  HTMLOListElement,\n  React.ComponentPropsWithoutRef<\"ol\">\n>(({ className, ...props }, ref) => (\n  <ol\n    ref={ref}\n    className={cn(\n      \"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5\",\n      className\n    )}\n    {...props}\n  />\n))\nBreadcrumbList.displayName = \"BreadcrumbList\"\n\nconst BreadcrumbItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentPropsWithoutRef<\"li\">\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    className={cn(\"inline-flex items-center gap-1.5\", className)}\n    {...props}\n  />\n))\nBreadcrumbItem.displayName = \"BreadcrumbItem\"\n\nconst BreadcrumbLink = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentPropsWithoutRef<\"a\"> & {\n    asChild?: boolean\n  }\n>(({ asChild, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      ref={ref}\n      className={cn(\"transition-colors hover:text-foreground\", className)}\n      {...props}\n    />\n  )\n})\nBreadcrumbLink.displayName = \"BreadcrumbLink\"\n\nconst BreadcrumbPage = React.forwardRef<\n  HTMLSpanElement,\n  React.ComponentPropsWithoutRef<\"span\">\n>(({ className, ...props }, ref) => (\n  <span\n    ref={ref}\n    role=\"link\"\n    aria-disabled=\"true\"\n    aria-current=\"page\"\n    className={cn(\"font-normal text-foreground\", className)}\n    {...props}\n  />\n))\nBreadcrumbPage.displayName = \"BreadcrumbPage\"\n\nconst BreadcrumbSeparator = ({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) => (\n  <li\n    role=\"presentation\"\n    aria-hidden=\"true\"\n    className={cn(\"[&>svg]:w-3.5 [&>svg]:h-3.5\", className)}\n    {...props}\n  >\n    {children ?? <ChevronRight />}\n  </li>\n)\nBreadcrumbSeparator.displayName = \"BreadcrumbSeparator\"\n\nconst BreadcrumbEllipsis = ({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) => (\n  <span\n    role=\"presentation\"\n    aria-hidden=\"true\"\n    className={cn(\"flex h-9 w-9 items-center justify-center\", className)}\n    {...props}\n  >\n    <MoreHorizontal className=\"h-4 w-4\" />\n    <span className=\"sr-only\">More</span>\n  </span>\n)\nBreadcrumbEllipsis.displayName = \"BreadcrumbElipssis\"\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n}\n"
  },
  {
    "path": "src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90\",\n        outline:\n          \"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-md px-8\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\"\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "src/components/ui/calendar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { DayPicker } from \"react-day-picker\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nexport type CalendarProps = React.ComponentProps<typeof DayPicker>\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  ...props\n}: CalendarProps) {\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\"p-3 bg-background\", className)}\n      classNames={{\n        months: \"flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0\",\n        month: \"space-y-4\",\n        caption: \"flex justify-center pt-1 relative items-center\",\n        caption_label: \"text-sm font-medium\",\n        nav: \"space-x-1 flex items-center\",\n        nav_button: cn(\n          buttonVariants({ variant: \"outline\" }),\n          \"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100\"\n        ),\n        nav_button_previous: \"absolute left-1\",\n        nav_button_next: \"absolute right-1\",\n        table: \"w-full border-collapse space-y-1\",\n        head_row: \"flex\",\n        head_cell:\n          \"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]\",\n        row: \"flex w-full mt-2\",\n        cell: cn(\n          \"h-9 w-9 text-center text-sm p-0 relative\",\n          \"[&:has([aria-selected].day-range-end)]:rounded-r-md\",\n          \"[&:has([aria-selected].day-outside)]:bg-accent/50\",\n          \"[&:has([aria-selected])]:bg-accent\",\n          \"first:[&:has([aria-selected])]:rounded-l-md\",\n          \"last:[&:has([aria-selected])]:rounded-r-md\",\n          \"focus-within:relative focus-within:z-20\"\n        ),\n        day: cn(\n          buttonVariants({ variant: \"ghost\" }),\n          \"h-9 w-9 p-0 font-normal aria-selected:opacity-100\"\n        ),\n        day_range_end: \"day-range-end\",\n        day_selected:\n          \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\",\n        day_today: \"bg-accent text-accent-foreground\",\n        day_outside:\n          \"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground\",\n        day_disabled: \"text-muted-foreground opacity-50\",\n        day_range_middle:\n          \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n        day_hidden: \"invisible\",\n        ...classNames,\n      }}\n      components={{\n        Chevron: ({ orientation, className }) => {\n          if (orientation === \"left\") {\n            return <ChevronLeft className={cn(\"h-4 w-4\", className)} />\n          }\n          if (orientation === \"right\") {\n            return <ChevronRight className={cn(\"h-4 w-4\", className)} />\n          }\n          return <span className={cn(\"h-4 w-4\", className)} />\n        },\n      }}\n      {...props}\n    />\n  )\n}\nCalendar.displayName = \"Calendar\"\n\nexport { Calendar }\n"
  },
  {
    "path": "src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-md border bg-card text-card-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nCard.displayName = \"Card\"\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-4\", className)}\n    {...props}\n  />\n))\nCardHeader.displayName = \"CardHeader\"\n\nconst CardTitle = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"font-semibold leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nCardTitle.displayName = \"CardTitle\"\n\nconst CardDescription = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nCardDescription.displayName = \"CardDescription\"\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-4 pt-0\", className)} {...props} />\n))\nCardContent.displayName = \"CardContent\"\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-4 pt-0\", className)}\n    {...props}\n  />\n))\nCardFooter.displayName = \"CardFooter\"\n\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }\n"
  },
  {
    "path": "src/components/ui/carousel.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType,\n} from \"embla-carousel-react\"\nimport { ArrowLeft, ArrowRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\ntype CarouselApi = UseEmblaCarouselType[1]\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>\ntype CarouselOptions = UseCarouselParameters[0]\ntype CarouselPlugin = UseCarouselParameters[1]\n\ntype CarouselProps = {\n  opts?: CarouselOptions\n  plugins?: CarouselPlugin\n  orientation?: \"horizontal\" | \"vertical\"\n  setApi?: (api: CarouselApi) => void\n}\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0]\n  api: ReturnType<typeof useEmblaCarousel>[1]\n  scrollPrev: () => void\n  scrollNext: () => void\n  canScrollPrev: boolean\n  canScrollNext: boolean\n} & CarouselProps\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null)\n\nfunction useCarousel() {\n  const context = React.useContext(CarouselContext)\n\n  if (!context) {\n    throw new Error(\"useCarousel must be used within a <Carousel />\")\n  }\n\n  return context\n}\n\nconst Carousel = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & CarouselProps\n>(\n  (\n    {\n      orientation = \"horizontal\",\n      opts,\n      setApi,\n      plugins,\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const [carouselRef, api] = useEmblaCarousel(\n      {\n        ...opts,\n        axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n      },\n      plugins\n    )\n    const [canScrollPrev, setCanScrollPrev] = React.useState(false)\n    const [canScrollNext, setCanScrollNext] = React.useState(false)\n\n    const onSelect = React.useCallback((api: CarouselApi) => {\n      if (!api) {\n        return\n      }\n\n      setCanScrollPrev(api.canScrollPrev())\n      setCanScrollNext(api.canScrollNext())\n    }, [])\n\n    const scrollPrev = React.useCallback(() => {\n      api?.scrollPrev()\n    }, [api])\n\n    const scrollNext = React.useCallback(() => {\n      api?.scrollNext()\n    }, [api])\n\n    const handleKeyDown = React.useCallback(\n      (event: React.KeyboardEvent<HTMLDivElement>) => {\n        if (event.key === \"ArrowLeft\") {\n          event.preventDefault()\n          scrollPrev()\n        } else if (event.key === \"ArrowRight\") {\n          event.preventDefault()\n          scrollNext()\n        }\n      },\n      [scrollPrev, scrollNext]\n    )\n\n    React.useEffect(() => {\n      if (!api || !setApi) {\n        return\n      }\n\n      setApi(api)\n    }, [api, setApi])\n\n    React.useEffect(() => {\n      if (!api) {\n        return\n      }\n\n      onSelect(api)\n      api.on(\"reInit\", onSelect)\n      api.on(\"select\", onSelect)\n\n      return () => {\n        api?.off(\"select\", onSelect)\n      }\n    }, [api, onSelect])\n\n    return (\n      <CarouselContext.Provider\n        value={{\n          carouselRef,\n          api: api,\n          opts,\n          orientation:\n            orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n          scrollPrev,\n          scrollNext,\n          canScrollPrev,\n          canScrollNext,\n        }}\n      >\n        <div\n          ref={ref}\n          onKeyDownCapture={handleKeyDown}\n          className={cn(\"relative\", className)}\n          role=\"region\"\n          aria-roledescription=\"carousel\"\n          {...props}\n        >\n          {children}\n        </div>\n      </CarouselContext.Provider>\n    )\n  }\n)\nCarousel.displayName = \"Carousel\"\n\nconst CarouselContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { carouselRef, orientation } = useCarousel()\n\n  return (\n    <div ref={carouselRef} className=\"overflow-hidden\">\n      <div\n        ref={ref}\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n})\nCarouselContent.displayName = \"CarouselContent\"\n\nconst CarouselItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { orientation } = useCarousel()\n\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      className={cn(\n        \"min-w-0 shrink-0 grow-0 basis-full\",\n        orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nCarouselItem.displayName = \"CarouselItem\"\n\nconst CarouselPrevious = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n  const { orientation, scrollPrev, canScrollPrev } = useCarousel()\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute  h-8 w-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"-left-12 top-1/2 -translate-y-1/2\"\n          : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className\n      )}\n      disabled={!canScrollPrev}\n      onClick={scrollPrev}\n      {...props}\n    >\n      <ArrowLeft className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Previous slide</span>\n    </Button>\n  )\n})\nCarouselPrevious.displayName = \"CarouselPrevious\"\n\nconst CarouselNext = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n  const { orientation, scrollNext, canScrollNext } = useCarousel()\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute h-8 w-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"-right-12 top-1/2 -translate-y-1/2\"\n          : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className\n      )}\n      disabled={!canScrollNext}\n      onClick={scrollNext}\n      {...props}\n    >\n      <ArrowRight className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Next slide</span>\n    </Button>\n  )\n})\nCarouselNext.displayName = \"CarouselNext\"\n\nexport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext,\n}\n"
  },
  {
    "path": "src/components/ui/checkbox.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Check } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n      className\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn(\"flex items-center justify-center text-current\")}\n    >\n      <Check className=\"h-4 w-4\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n))\nCheckbox.displayName = CheckboxPrimitive.Root.displayName\n\nexport { Checkbox }\n"
  },
  {
    "path": "src/components/ui/collapsible.tsx",
    "content": "\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nconst Collapsible = CollapsiblePrimitive.Root\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n"
  },
  {
    "path": "src/components/ui/command.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\"\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nCommand.displayName = CommandPrimitive.displayName\n\nconst CommandDialog = ({ children, ...props }: DialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0\">\n        <Command shouldFilter={false} className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>\n>(({ className, ...props }, ref) => (\n  <div className=\"flex items-center border-b px-3\" cmdk-input-wrapper=\"\">\n    <Search className=\"mr-2 h-4 w-4 shrink-0 opacity-50\" />\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  </div>\n))\n\nCommandInput.displayName = CommandPrimitive.Input.displayName\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n))\n\nCommandList.displayName = CommandPrimitive.List.displayName\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n))\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      className\n    )}\n    {...props}\n  />\n))\n\nCommandItem.displayName = CommandPrimitive.Item.displayName\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\nCommandShortcut.displayName = \"CommandShortcut\"\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "src/components/ui/context-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ContextMenu = ContextMenuPrimitive.Root\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\nconst ContextMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {\n    inset?: boolean\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <ContextMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground\",\n      inset && \"pl-2\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto h-4 w-4\" />\n  </ContextMenuPrimitive.SubTrigger>\n))\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName\n\nconst ContextMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName\n\nconst ContextMenuContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Portal>\n    <ContextMenuPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </ContextMenuPrimitive.Portal>\n))\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName\n\nconst ContextMenuItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      inset && \"pl-2\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName\n\nconst ContextMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <ContextMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.CheckboxItem>\n))\nContextMenuCheckboxItem.displayName =\n  ContextMenuPrimitive.CheckboxItem.displayName\n\nconst ContextMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <ContextMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <ContextMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-4 w-4 fill-current\" />\n      </ContextMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </ContextMenuPrimitive.RadioItem>\n))\nContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName\n\nconst ContextMenuLabel = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <ContextMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-xs font-semibold text-foreground\",\n      inset && \"pl-2\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName\n\nconst ContextMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName\n\nconst ContextMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\nContextMenuShortcut.displayName = \"ContextMenuShortcut\"\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "src/components/ui/dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Dialog = DialogPrimitive.Root\n\nconst DialogTrigger = DialogPrimitive.Trigger\n\nconst DialogPortal = DialogPrimitive.Portal\n\nconst DialogClose = DialogPrimitive.Close\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {\n    showCloseButton?: boolean\n  }\n>(({ className, children, showCloseButton = true, ...props }, ref) => (\n  <DialogPortal>\n    <DialogTitle className=\"sr-only\">DialogTitle</DialogTitle>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      {showCloseButton ? (\n        <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      ) : null}\n    </DialogPrimitive.Content>\n  </DialogPortal>\n))\nDialogContent.displayName = DialogPrimitive.Content.displayName\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogHeader.displayName = \"DialogHeader\"\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nDialogFooter.displayName = \"DialogFooter\"\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className\n    )}\n    {...props}\n  />\n))\nDialogTitle.displayName = DialogPrimitive.Title.displayName\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nDialogDescription.displayName = DialogPrimitive.Description.displayName\n\nexport {\n  Dialog,\n  DialogPortal,\n  DialogOverlay,\n  DialogTrigger,\n  DialogClose,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n}\n"
  },
  {
    "path": "src/components/ui/diff-viewer.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { diffLines, diffWords } from \"diff\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface DiffViewerProps {\n  /** Original content (before) */\n  original: string\n  /** Modified content (after) */\n  modified: string\n  /** Display mode: 'lines' for line-by-line, 'words' for word-by-word */\n  mode?: \"lines\" | \"words\"\n  /** Maximum height for the diff container */\n  maxHeight?: string | number\n  /** Show line numbers */\n  showLineNumbers?: boolean\n  /** Additional className */\n  className?: string\n}\n\ninterface DiffLine {\n  number: number\n  content: string\n  type: \"added\" | \"removed\" | \"unchanged\" | \"empty\"\n}\n\nexport function DiffViewer({\n  original,\n  modified,\n  mode = \"lines\",\n  maxHeight = 600,\n  showLineNumbers = true,\n  className,\n}: DiffViewerProps) {\n  const [diffData, setDiffData] = React.useState<DiffLine[]>([])\n  const [showAllChangedWarning, setShowAllChangedWarning] = React.useState(false)\n\n  React.useEffect(() => {\n    // 标准化行尾符，避免因为行尾符不同导致的误判\n    const normalizeLineEndings = (text: string) => text.replace(/\\r\\n/g, '\\n')\n    const normalizedOriginal = normalizeLineEndings(original)\n    const normalizedModified = normalizeLineEndings(modified)\n\n    if (mode === \"lines\") {\n      const changes = diffLines(normalizedOriginal, normalizedModified)\n      const lines: DiffLine[] = []\n\n      let originalLineNum = 1\n      let modifiedLineNum = 1\n\n      changes.forEach((part) => {\n        const partLines = part.value.split(\"\\n\")\n        // Remove last empty line if exists (split adds extra)\n        if (partLines[partLines.length - 1] === \"\") {\n          partLines.pop()\n        }\n\n        partLines.forEach((line) => {\n          if (part.removed) {\n            lines.push({\n              number: originalLineNum++,\n              content: line,\n              type: \"removed\",\n            })\n          } else if (part.added) {\n            lines.push({\n              number: modifiedLineNum++,\n              content: line,\n              type: \"added\",\n            })\n          } else {\n            lines.push({\n              number: showLineNumbers ? originalLineNum++ : 0,\n              content: line,\n              type: \"unchanged\",\n            })\n            if (showLineNumbers) modifiedLineNum++\n          }\n        })\n      })\n\n      // 检查是否所有行都被修改了（不包括空行）\n      const nonEmptyLines = lines.filter(l => l.content.trim() !== '')\n      const changedLines = nonEmptyLines.filter(l => l.type === \"added\" || l.type === \"removed\")\n\n      if (nonEmptyLines.length > 0 && changedLines.length === nonEmptyLines.length) {\n        setShowAllChangedWarning(true)\n      } else {\n        setShowAllChangedWarning(false)\n      }\n\n      setDiffData(lines)\n    } else {\n      // Word mode\n      const changes = diffWords(normalizedOriginal, normalizedModified)\n      let result = \"\"\n      changes.forEach((part) => {\n        const className = part.added\n          ? \"bg-green-500/30 text-green-900 dark:text-green-100\"\n          : part.removed\n          ? \"bg-red-500/30 text-red-900 dark:text-red-100 line-through\"\n          : \"\"\n        result += `<span class=\"${className}\">${part.value}</span>`\n      })\n\n      // Convert to lines format for consistent rendering\n      setDiffData([\n        {\n          number: 0,\n          content: result,\n          type: \"unchanged\",\n        },\n      ])\n    }\n  }, [original, modified, mode, showLineNumbers])\n\n  if (diffData.length === 0) {\n    return null\n  }\n\n  const containerStyle = maxHeight\n    ? { maxHeight: typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight }\n    : undefined\n\n  return (\n    <div\n      className={cn(\n        \"overflow-auto rounded-lg border bg-muted/50 text-sm font-mono\",\n        className\n      )}\n      style={containerStyle}\n    >\n      <div className=\"sticky top-0 z-10 flex border-b bg-muted px-4 py-2 font-medium text-xs text-muted-foreground\">\n        <span className=\"flex-1\">\n          {mode === \"lines\" ? \"Line Diff\" : \"Word Diff\"}\n        </span>\n        <span className=\"text-xs\">\n          {diffData.filter((l) => l.type === \"added\").length} additions,{\" \"}\n          {diffData.filter((l) => l.type === \"removed\").length} deletions\n        </span>\n      </div>\n\n      {/* Warning when all lines are changed */}\n      {showAllChangedWarning && mode === \"lines\" && (\n        <div className=\"border-b border-yellow-500/30 bg-yellow-500/5 px-4 py-2 text-xs text-yellow-900 dark:text-yellow-100\">\n          ⚠️ All lines have been modified. Showing full file comparison.\n        </div>\n      )}\n\n      <div className=\"flex\">\n        <div className=\"flex-1\">\n          {mode === \"lines\" ? (\n            diffData.filter((l) => l.type === \"added\" || l.type === \"removed\").map((line, idx) => (\n              <div\n                key={idx}\n                className={cn(\n                  \"flex border-b border-l-2 last:border-b-0\",\n                  line.type === \"added\" && \"border-l-green-500 bg-green-500/10\",\n                  line.type === \"removed\" && \"border-l-red-500 bg-red-500/10\",\n                  line.type === \"unchanged\" && \"border-l-transparent\"\n                )}\n              >\n                {showLineNumbers && (\n                  <div\n                    className={cn(\n                      \"w-12 shrink-0 border-r px-2 text-center text-xs text-muted-foreground\",\n                      line.type === \"added\" && \"bg-green-500/5\",\n                      line.type === \"removed\" && \"bg-red-500/5\"\n                    )}\n                  >\n                    {line.number || \"\"}\n                  </div>\n                )}\n                <pre className=\"flex-1 whitespace-pre-wrap break-words px-3 py-1\">\n                  <code\n                    className={cn(\n                      line.type === \"added\" && \"text-green-900 dark:text-green-100\",\n                      line.type === \"removed\" && \"text-red-900 dark:text-red-100\",\n                      line.type === \"unchanged\" && \"text-foreground\"\n                    )}\n                    dangerouslySetInnerHTML={{\n                      __html: line.content,\n                    }}\n                  />\n                </pre>\n              </div>\n            ))\n          ) : (\n            <div className=\"p-4\">\n              <div\n                className=\"whitespace-pre-wrap break-words\"\n                dangerouslySetInnerHTML={{\n                  __html: diffData[0]?.content || \"\",\n                }}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\n/**\n * Inline diff component for showing changes in a compact format\n */\nexport interface InlineDiffProps {\n  original: string\n  modified: string\n  className?: string\n}\n\nexport function InlineDiff({ original, modified, className }: InlineDiffProps) {\n  const changes = diffWords(original, modified)\n\n  return (\n    <span className={cn(\"text-sm\", className)}>\n      {changes.map((part, idx) => (\n        <span\n          key={idx}\n          className={cn(\n            part.added &&\n              \"bg-green-500/30 text-green-900 dark:text-green-100 rounded px-0.5\",\n            part.removed &&\n              \"bg-red-500/30 text-red-900 dark:text-red-100 line-through rounded px-0.5\"\n          )}\n        >\n          {part.value}\n        </span>\n      ))}\n    </span>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/drawer.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Drawer = ({\n  shouldScaleBackground = true,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (\n  <DrawerPrimitive.Root\n    shouldScaleBackground={shouldScaleBackground}\n    {...props}\n  />\n)\nDrawer.displayName = \"Drawer\"\n\nconst DrawerTrigger = DrawerPrimitive.Trigger\n\nconst DrawerPortal = DrawerPrimitive.Portal\n\nconst DrawerClose = DrawerPrimitive.Close\n\nconst DrawerOverlay = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Overlay\n    ref={ref}\n    className={cn(\"fixed inset-0 z-50 bg-black/80\", className)}\n    {...props}\n  />\n))\nDrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName\n\nconst DrawerContent = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DrawerPortal>\n    <DrawerOverlay />\n    <DrawerPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background\",\n        className\n      )}\n      {...props}\n    >\n      <div className=\"mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted\" />\n      {children}\n    </DrawerPrimitive.Content>\n  </DrawerPortal>\n))\nDrawerContent.displayName = \"DrawerContent\"\n\nconst DrawerHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\"grid gap-1.5 p-4 text-center sm:text-left\", className)}\n    {...props}\n  />\n)\nDrawerHeader.displayName = \"DrawerHeader\"\n\nconst DrawerFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n    {...props}\n  />\n)\nDrawerFooter.displayName = \"DrawerFooter\"\n\nconst DrawerTitle = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className\n    )}\n    {...props}\n  />\n))\nDrawerTitle.displayName = DrawerPrimitive.Title.displayName\n\nconst DrawerDescription = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nDrawerDescription.displayName = DrawerPrimitive.Description.displayName\n\nexport {\n  Drawer,\n  DrawerPortal,\n  DrawerOverlay,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerContent,\n  DrawerHeader,\n  DrawerFooter,\n  DrawerTitle,\n  DrawerDescription,\n}\n"
  },
  {
    "path": "src/components/ui/dropdown-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto\" />\n  </DropdownMenuPrimitive.SubTrigger>\n))\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n))\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n))\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n))\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)}\n      {...props}\n    />\n  )\n}\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\"\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n}\n"
  },
  {
    "path": "src/components/ui/empty.tsx",
    "content": "import { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Empty({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty\"\n      className={cn(\n        \"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-header\"\n      className={cn(\n        \"flex max-w-sm flex-col items-center gap-2 text-center\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst emptyMediaVariants = cva(\n  \"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        icon: \"bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction EmptyMedia({\n  className,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof emptyMediaVariants>) {\n  return (\n    <div\n      data-slot=\"empty-icon\"\n      data-variant={variant}\n      className={cn(emptyMediaVariants({ variant, className }))}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-title\"\n      className={cn(\"text-lg font-medium tracking-tight\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <div\n      data-slot=\"empty-description\"\n      className={cn(\n        \"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction EmptyContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"empty-content\"\n      className={cn(\n        \"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Empty,\n  EmptyHeader,\n  EmptyTitle,\n  EmptyDescription,\n  EmptyContent,\n  EmptyMedia,\n}\n"
  },
  {
    "path": "src/components/ui/enhanced-context-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useTextSize } from \"@/contexts/text-size-context\"\n\nconst ContextMenu = ContextMenuPrimitive.Root\n\nconst ContextMenuTrigger = ContextMenuPrimitive.Trigger\n\nconst ContextMenuGroup = ContextMenuPrimitive.Group\n\nconst ContextMenuPortal = ContextMenuPrimitive.Portal\n\nconst ContextMenuSub = ContextMenuPrimitive.Sub\n\nconst ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup\n\ninterface ContextMenuSubTriggerProps extends React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> {\n  inset?: boolean\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,\n  ContextMenuSubTriggerProps\n>(({ className, inset, children, menuType = 'file', ...props }, ref) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n  \n  return (\n    <ContextMenuPrimitive.SubTrigger\n      ref={ref}\n      className={cn(\n        `flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-${textSize} outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground`,\n        inset && \"pl-2\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRight className=\"ml-auto h-4 w-4\" />\n    </ContextMenuPrimitive.SubTrigger>\n  )\n})\nContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName\n\nconst ContextMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-56 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName\n\nconst ContextMenuContent = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Portal>\n    <ContextMenuPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"z-50 min-w-56 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </ContextMenuPrimitive.Portal>\n))\nContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName\n\ninterface ContextMenuItemProps extends React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> {\n  inset?: boolean\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Item>,\n  ContextMenuItemProps\n>(({ className, inset, menuType = 'file', onClick, ...props }, ref) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n\n  const handleClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {\n    // 阻止事件冒泡，防止触发父元素的点击事件（如文件夹折叠/展开）\n    e.stopPropagation();\n    if (onClick) {\n      onClick(e);\n    }\n  }\n\n  return (\n    <ContextMenuPrimitive.Item\n      ref={ref}\n      className={cn(\n        `relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-${textSize} outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,\n        inset && \"pl-2\",\n        className\n      )}\n      onClick={handleClick}\n      {...props}\n    />\n  )\n})\nContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName\n\ninterface ContextMenuCheckboxItemProps extends React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> {\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,\n  ContextMenuCheckboxItemProps\n>(({ className, children, checked, menuType = 'file', ...props }, ref) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n  \n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      ref={ref}\n      className={cn(\n        `relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-${textSize} outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <Check className=\"h-4 w-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  )\n})\nContextMenuCheckboxItem.displayName =\n  ContextMenuPrimitive.CheckboxItem.displayName\n\ninterface ContextMenuRadioItemProps extends React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> {\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,\n  ContextMenuRadioItemProps\n>(({ className, children, menuType = 'file', ...props }, ref) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n  \n  return (\n    <ContextMenuPrimitive.RadioItem\n      ref={ref}\n      className={cn(\n        `relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-${textSize} outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50`,\n        className\n      )}\n      {...props}\n    >\n      <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <Circle className=\"h-4 w-4 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  )\n})\nContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName\n\ninterface ContextMenuLabelProps extends React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> {\n  inset?: boolean\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuLabel = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Label>,\n  ContextMenuLabelProps\n>(({ className, inset, menuType = 'file', ...props }, ref) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n  \n  return (\n    <ContextMenuPrimitive.Label\n      ref={ref}\n      className={cn(\n        `px-2 py-1.5 text-${textSize} font-semibold text-foreground`,\n        inset && \"pl-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName\n\nconst ContextMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof ContextMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <ContextMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n    {...props}\n  />\n))\nContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName\n\ninterface ContextMenuShortcutProps extends React.HTMLAttributes<HTMLSpanElement> {\n  menuType?: 'file' | 'record'\n}\n\nconst ContextMenuShortcut = ({\n  className,\n  menuType = 'file',\n  ...props\n}: ContextMenuShortcutProps) => {\n  const { getContextMenuTextSize } = useTextSize()\n  const textSize = getContextMenuTextSize(menuType)\n  \n  return (\n    <span\n      className={cn(\n        `ml-auto text-${textSize} tracking-widest text-muted-foreground`,\n        className\n      )}\n      {...props}\n    />\n  )\n}\nContextMenuShortcut.displayName = \"ContextMenuShortcut\"\n\nexport {\n  ContextMenu,\n  ContextMenuTrigger,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuCheckboxItem,\n  ContextMenuRadioItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuGroup,\n  ContextMenuPortal,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuRadioGroup,\n}\n"
  },
  {
    "path": "src/components/ui/expandable-tabs.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { useOnClickOutside } from \"usehooks-ts\";\nimport { cn } from \"@/lib/utils\";\nimport { LucideIcon } from \"lucide-react\";\n\ninterface Tab {\n  title: string;\n  icon: LucideIcon;\n  type?: never;\n}\n\ninterface Separator {\n  type: \"separator\";\n  title?: never;\n  icon?: never;\n}\n\ntype TabItem = Tab | Separator;\n\ninterface ExpandableTabsProps {\n  tabs: TabItem[];\n  className?: string;\n  activeColor?: string;\n  onChange?: (index: number | null) => void;\n  selected?: number | null;\n}\n\nconst buttonVariants = {\n  initial: {\n    gap: 0,\n    paddingLeft: \".375rem\",\n    paddingRight: \".375rem\",\n  },\n  animate: (isSelected: boolean) => ({\n    gap: isSelected ? \".375rem\" : 0,\n    paddingLeft: isSelected ? \"0.75rem\" : \".375rem\",\n    paddingRight: isSelected ? \"0.75rem\" : \".375rem\",\n  }),\n};\n\nconst spanVariants = {\n  initial: { width: 0, opacity: 0 },\n  animate: { width: \"auto\", opacity: 1 },\n  exit: { width: 0, opacity: 0 },\n};\n\nconst transition = { delay: 0.1, type: \"spring\" as const, bounce: 0, duration: 0.6 };\n\nexport function ExpandableTabs({\n  tabs,\n  className,\n  activeColor = \"text-primary\",\n  onChange,\n  selected: controlledSelected,\n}: ExpandableTabsProps) {\n  const [internalSelected, setInternalSelected] = React.useState<number | null>(null);\n  const outsideClickRef = React.useRef(null);\n\n  // Support controlled mode\n  const selected = controlledSelected !== undefined ? controlledSelected : internalSelected;\n\n  useOnClickOutside(outsideClickRef, () => {\n    if (controlledSelected === undefined) {\n      setInternalSelected(null);\n    }\n    onChange?.(null);\n  });\n\n  const handleSelect = (index: number) => {\n    if (controlledSelected === undefined) {\n      setInternalSelected(index);\n    }\n    onChange?.(index);\n  };\n\n  const Separator = () => (\n    <div className=\"mx-1 h-5 w-[1px] bg-border\" aria-hidden=\"true\" />\n  );\n\n  return (\n    <div\n      ref={outsideClickRef}\n      className={cn(\n        \"flex flex-wrap items-center gap-0.5 rounded-xl border bg-background p-0.5\",\n        className\n      )}\n    >\n      {tabs.map((tab, index) => {\n        if (tab.type === \"separator\") {\n          return <Separator key={`separator-${index}`} />;\n        }\n\n        const Icon = tab.icon;\n        return (\n          <motion.button\n            key={tab.title}\n            variants={buttonVariants}\n            initial={false}\n            animate=\"animate\"\n            custom={selected === index}\n            onClick={() => handleSelect(index)}\n            transition={transition}\n            className={cn(\n              \"relative flex items-center rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-300\",\n              selected === index\n                ? cn(\"bg-muted\", activeColor)\n                : \"text-muted-foreground hover:bg-muted hover:text-foreground opacity-70\"\n            )}\n          >\n            <Icon size={16} />\n            <AnimatePresence initial={false}>\n              {selected === index && (\n                <motion.span\n                  variants={spanVariants}\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  transition={transition}\n                  className=\"overflow-hidden whitespace-nowrap\"\n                >\n                  {tab.title}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </motion.button>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/ui/form.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport {\n  Controller,\n  ControllerProps,\n  FieldPath,\n  FieldValues,\n  FormProvider,\n  useFormContext,\n} from \"react-hook-form\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nconst Form = FormProvider\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n  name: TName\n}\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue\n)\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  )\n}\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext)\n  const itemContext = React.useContext(FormItemContext)\n  const { getFieldState, formState } = useFormContext()\n\n  const fieldState = getFieldState(fieldContext.name, formState)\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\")\n  }\n\n  const { id } = itemContext\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  }\n}\n\ntype FormItemContextValue = {\n  id: string\n}\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue\n)\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId()\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext.Provider>\n  )\n})\nFormItem.displayName = \"FormItem\"\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField()\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && \"text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  )\n})\nFormLabel.displayName = \"FormLabel\"\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  )\n})\nFormControl.displayName = \"FormControl\"\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField()\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-[0.8rem] text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n})\nFormDescription.displayName = \"FormDescription\"\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField()\n  const body = error ? String(error?.message) : children\n\n  if (!body) {\n    return null\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-[0.8rem] font-medium text-destructive\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  )\n})\nFormMessage.displayName = \"FormMessage\"\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n}\n"
  },
  {
    "path": "src/components/ui/hover-card.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst HoverCard = HoverCardPrimitive.Root\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = React.forwardRef<\n  React.ElementRef<typeof HoverCardPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <HoverCardPrimitive.Content\n    ref={ref}\n    align={align}\n    sideOffset={sideOffset}\n    className={cn(\n      \"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n"
  },
  {
    "path": "src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React.ComponentProps<\"input\">>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nInput.displayName = \"Input\"\n\nexport { Input }\n"
  },
  {
    "path": "src/components/ui/item.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from \"@/components/ui/separator\"\n\nfunction ItemGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      role=\"list\"\n      data-slot=\"item-group\"\n      className={cn(\"group/item-group flex flex-col\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ItemSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"item-separator\"\n      orientation=\"horizontal\"\n      className={cn(\"my-0\", className)}\n      {...props}\n    />\n  )\n}\n\nconst itemVariants = cva(\n  \"group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline: \"border-border\",\n        muted: \"bg-muted/50\",\n      },\n      size: {\n        default: \"gap-4 p-4 \",\n        sm: \"gap-2.5 px-4 py-3\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Item({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> &\n  VariantProps<typeof itemVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"div\"\n  return (\n    <Comp\n      data-slot=\"item\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(itemVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nconst itemMediaVariants = cva(\n  \"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none max-md:hidden\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        icon: \"bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4\",\n        image:\n          \"size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction ItemMedia({\n  className,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof itemMediaVariants>) {\n  return (\n    <div\n      data-slot=\"item-media\"\n      data-variant={variant}\n      className={cn(itemMediaVariants({ variant, className }))}\n      {...props}\n    />\n  )\n}\n\nfunction ItemContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-content\"\n      className={cn(\n        \"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-title\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-sm font-medium leading-snug\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"item-description\"\n      className={cn(\n        \"text-muted-foreground line-clamp-2 text-balance text-sm font-normal leading-normal max-md:line-clamp-none\",\n        \"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemActions({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-actions\"\n      className={cn(\"flex items-center gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction ItemHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-header\"\n      className={cn(\n        \"flex basis-full items-center justify-between gap-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ItemFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"item-footer\"\n      className={cn(\n        \"flex basis-full items-center justify-between gap-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Item,\n  ItemMedia,\n  ItemContent,\n  ItemActions,\n  ItemGroup,\n  ItemSeparator,\n  ItemTitle,\n  ItemDescription,\n  ItemHeader,\n  ItemFooter,\n}\n"
  },
  {
    "path": "src/components/ui/kbd.tsx",
    "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nconst Kbd = React.forwardRef<\n  HTMLSpanElement,\n  React.HTMLAttributes<HTMLSpanElement>\n>(({ className, ...props }, ref) => {\n  return (\n    <span\n      ref={ref}\n      className={cn(\n        \"inline-flex items-center justify-center rounded px-1 py-0 text-[10px] text-muted-foreground/70\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nKbd.displayName = \"Kbd\"\n\nconst KbdGroup = React.forwardRef<\n  HTMLSpanElement,\n  React.HTMLAttributes<HTMLSpanElement>\n>(({ className, ...props }, ref) => {\n  return (\n    <span\n      ref={ref}\n      className={cn(\"inline-flex items-center gap-0.5\", className)}\n      {...props}\n    />\n  )\n})\nKbdGroup.displayName = \"KbdGroup\"\n\nexport { Kbd, KbdGroup }\n"
  },
  {
    "path": "src/components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n)\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n))\nLabel.displayName = LabelPrimitive.Root.displayName\n\nexport { Label }\n"
  },
  {
    "path": "src/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverAnchor = PopoverPrimitive.Anchor\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "src/components/ui/progress.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Progress = React.forwardRef<\n  React.ElementRef<typeof ProgressPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>\n>(({ className, value, ...props }, ref) => (\n  <ProgressPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative h-2 w-full overflow-hidden rounded-full bg-primary/20\",\n      className\n    )}\n    {...props}\n  >\n    <ProgressPrimitive.Indicator\n      className=\"h-full w-full flex-1 bg-primary transition-all\"\n      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n    />\n  </ProgressPrimitive.Root>\n))\nProgress.displayName = ProgressPrimitive.Root.displayName\n\nexport { Progress }\n"
  },
  {
    "path": "src/components/ui/radio-group.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport { Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst RadioGroup = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Root\n      className={cn(\"grid gap-2\", className)}\n      {...props}\n      ref={ref}\n    />\n  )\n})\nRadioGroup.displayName = RadioGroupPrimitive.Root.displayName\n\nconst RadioGroupItem = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Item\n      ref={ref}\n      className={cn(\n        \"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator className=\"flex items-center justify-center\">\n        <Circle className=\"h-3.5 w-3.5 fill-primary\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  )\n})\nRadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "src/components/ui/resizable.tsx",
    "content": "\"use client\"\n\nimport { GripVertical } from \"lucide-react\"\nimport * as ResizablePrimitive from \"react-resizable-panels\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ResizablePanelGroup = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n  <ResizablePrimitive.PanelGroup\n    className={cn(\n      \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n      className\n    )}\n    {...props}\n  />\n)\n\nconst ResizablePanel = ResizablePrimitive.Panel\n\nconst ResizableHandle = ({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean\n}) => (\n  <ResizablePrimitive.PanelResizeHandle\n    className={cn(\n      \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n      className\n    )}\n    {...props}\n  >\n    {withHandle && (\n      <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n        <GripVertical className=\"h-2.5 w-2.5\" />\n      </div>\n    )}\n  </ResizablePrimitive.PanelResizeHandle>\n)\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle }\n"
  },
  {
    "path": "src/components/ui/scroll-area.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>\n>(({ className, children, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn(\"relative overflow-hidden\", className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n    <ScrollBar />\n    <ScrollAreaPrimitive.Corner />\n  </ScrollAreaPrimitive.Root>\n))\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      \"flex touch-none select-none transition-colors\",\n      orientation === \"vertical\" &&\n        \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n      orientation === \"horizontal\" &&\n        \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n      className\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n))\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "src/components/ui/select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, ChevronDown, ChevronUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Select = SelectPrimitive.Root\n\nconst SelectGroup = SelectPrimitive.Group\n\nconst SelectValue = SelectPrimitive.Value\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n))\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n))\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n))\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = \"popper\", ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        position === \"popper\" &&\n          \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n        className\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          \"p-1\",\n          position === \"popper\" &&\n            \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\"\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n))\nSelectContent.displayName = SelectPrimitive.Content.displayName\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 text-sm font-semibold\", className)}\n    {...props}\n  />\n))\nSelectLabel.displayName = SelectPrimitive.Label.displayName\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n))\nSelectItem.displayName = SelectPrimitive.Item.displayName\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton,\n}\n"
  },
  {
    "path": "src/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = \"horizontal\", decorative = true, ...props },\n    ref\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border\",\n        orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n)\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport { Separator }\n"
  },
  {
    "path": "src/components/ui/sheet.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Sheet = SheetPrimitive.Root\n\nconst SheetTrigger = SheetPrimitive.Trigger\n\nconst SheetClose = SheetPrimitive.Close\n\nconst SheetPortal = SheetPrimitive.Portal\n\nconst SheetOverlay = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Overlay\n    className={cn(\n      \"fixed left-0 right-0 bottom-0 top-[36px] z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  />\n))\nSheetOverlay.displayName = SheetPrimitive.Overlay.displayName\n\nconst sheetVariants = cva(\n  \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out\",\n  {\n    variants: {\n      side: {\n        top: \"inset-x-0 top-[36px] border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n        bottom:\n          \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n        left: \"top-[36px] bottom-0 left-0 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n        right:\n          \"top-[36px] bottom-0 right-0 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n      },\n    },\n    defaultVariants: {\n      side: \"right\",\n    },\n  }\n)\n\ninterface SheetContentProps\n  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,\n    VariantProps<typeof sheetVariants> {\n  hideCloseButton?: boolean\n}\n\nconst SheetContent = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Content>,\n  SheetContentProps\n>(({ side = \"right\", className, children, hideCloseButton = false, ...props }, ref) => (\n  <SheetPortal>\n    <SheetOverlay />\n    <SheetPrimitive.Content\n      ref={ref}\n      className={cn(sheetVariants({ side }), className)}\n      {...props}\n    >\n      {!hideCloseButton ? (\n        <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      ) : null}\n      {children}\n    </SheetPrimitive.Content>\n  </SheetPortal>\n))\nSheetContent.displayName = SheetPrimitive.Content.displayName\n\nconst SheetHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className\n    )}\n    {...props}\n  />\n)\nSheetHeader.displayName = \"SheetHeader\"\n\nconst SheetFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className\n    )}\n    {...props}\n  />\n)\nSheetFooter.displayName = \"SheetFooter\"\n\nconst SheetTitle = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold text-foreground\", className)}\n    {...props}\n  />\n))\nSheetTitle.displayName = SheetPrimitive.Title.displayName\n\nconst SheetDescription = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nSheetDescription.displayName = SheetPrimitive.Description.displayName\n\nexport {\n  Sheet,\n  SheetPortal,\n  SheetOverlay,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n}\n"
  },
  {
    "path": "src/components/ui/shine-border.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * Width of the border in pixels\n   * @default 1\n   */\n  borderWidth?: number\n  /**\n   * Duration of the animation in seconds\n   * @default 14\n   */\n  duration?: number\n  /**\n   * Color of the border, can be a single color or an array of colors\n   * @default \"#000000\"\n   */\n  shineColor?: string | string[]\n}\n\n/**\n * Shine Border\n *\n * An animated background border effect component with configurable properties.\n */\nexport function ShineBorder({\n  borderWidth = 1,\n  duration = 14,\n  shineColor = \"#000000\",\n  className,\n  style,\n  ...props\n}: ShineBorderProps) {\n  return (\n    <div\n      style={\n        {\n          \"--border-width\": `${borderWidth}px`,\n          \"--duration\": `${duration}s`,\n          backgroundImage: `radial-gradient(transparent,transparent, ${\n            Array.isArray(shineColor) ? shineColor.join(\",\") : shineColor\n          },transparent,transparent)`,\n          backgroundSize: \"300% 300%\",\n          mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,\n          WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,\n          WebkitMaskComposite: \"xor\",\n          maskComposite: \"exclude\",\n          padding: \"var(--border-width)\",\n          ...style,\n        } as React.CSSProperties\n      }\n      className={cn(\n        \"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "src/components/ui/sidebar.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { VariantProps, cva } from \"class-variance-authority\"\nimport { PanelLeft } from \"lucide-react\"\n\nimport { useIsMobile } from \"@/hooks/use-mobile\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Sheet, SheetContent } from \"@/components/ui/sheet\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar:state\"\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7\nconst SIDEBAR_WIDTH = \"16rem\"\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\"\nconst SIDEBAR_WIDTH_ICON = \"3rem\"\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\"\n\ntype SidebarContext = {\n  state: \"expanded\" | \"collapsed\"\n  open: boolean\n  setOpen: (open: boolean) => void\n  openMobile: boolean\n  setOpenMobile: (open: boolean) => void\n  isMobile: boolean\n  toggleSidebar: () => void\n}\n\nconst SidebarContext = React.createContext<SidebarContext | null>(null)\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext)\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\")\n  }\n\n  return context\n}\n\nconst SidebarProvider = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    defaultOpen?: boolean\n    open?: boolean\n    onOpenChange?: (open: boolean) => void\n  }\n>(\n  (\n    {\n      defaultOpen = true,\n      open: openProp,\n      onOpenChange: setOpenProp,\n      className,\n      style,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const isMobile = useIsMobile()\n    const [openMobile, setOpenMobile] = React.useState(false)\n\n    // This is the internal state of the sidebar.\n    // We use openProp and setOpenProp for control from outside the component.\n    const [_open, _setOpen] = React.useState(defaultOpen)\n    const open = openProp ?? _open\n    const setOpen = React.useCallback(\n      (value: boolean | ((value: boolean) => boolean)) => {\n        const openState = typeof value === \"function\" ? value(open) : value\n        if (setOpenProp) {\n          setOpenProp(openState)\n        } else {\n          _setOpen(openState)\n        }\n\n        // This sets the cookie to keep the sidebar state.\n        document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`\n      },\n      [setOpenProp, open]\n    )\n\n    // Helper to toggle the sidebar.\n    const toggleSidebar = React.useCallback(() => {\n      return isMobile\n        ? setOpenMobile((open) => !open)\n        : setOpen((open) => !open)\n    }, [isMobile, setOpen, setOpenMobile])\n\n    // Adds a keyboard shortcut to toggle the sidebar.\n    React.useEffect(() => {\n      const handleKeyDown = (event: KeyboardEvent) => {\n        if (\n          event.key === SIDEBAR_KEYBOARD_SHORTCUT &&\n          (event.metaKey || event.ctrlKey)\n        ) {\n          event.preventDefault()\n          toggleSidebar()\n        }\n      }\n\n      window.addEventListener(\"keydown\", handleKeyDown)\n      return () => window.removeEventListener(\"keydown\", handleKeyDown)\n    }, [toggleSidebar])\n\n    // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n    // This makes it easier to style the sidebar with Tailwind classes.\n    const state = open ? \"expanded\" : \"collapsed\"\n\n    const contextValue = React.useMemo<SidebarContext>(\n      () => ({\n        state,\n        open,\n        setOpen,\n        isMobile,\n        openMobile,\n        setOpenMobile,\n        toggleSidebar,\n      }),\n      [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n    )\n\n    return (\n      <SidebarContext.Provider value={contextValue}>\n        <TooltipProvider delayDuration={0}>\n          <div\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH,\n                \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n                ...style,\n              } as React.CSSProperties\n            }\n            className={cn(\n              \"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar\",\n              className\n            )}\n            ref={ref}\n            {...props}\n          >\n            {children}\n          </div>\n        </TooltipProvider>\n      </SidebarContext.Provider>\n    )\n  }\n)\nSidebarProvider.displayName = \"SidebarProvider\"\n\nconst Sidebar = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    side?: \"left\" | \"right\"\n    variant?: \"sidebar\" | \"floating\" | \"inset\"\n    collapsible?: \"offcanvas\" | \"icon\" | \"none\"\n  }\n>(\n  (\n    {\n      side = \"left\",\n      variant = \"sidebar\",\n      collapsible = \"offcanvas\",\n      className,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    const { isMobile, state, openMobile, setOpenMobile } = useSidebar()\n\n    if (collapsible === \"none\") {\n      return (\n        <div\n          className={cn(\n            \"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground\",\n            className\n          )}\n          ref={ref}\n          {...props}\n        >\n          {children}\n        </div>\n      )\n    }\n\n    if (isMobile) {\n      return (\n        <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n          <SheetContent\n            data-sidebar=\"sidebar\"\n            data-mobile=\"true\"\n            className=\"w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n              } as React.CSSProperties\n            }\n            side={side}\n          >\n            <div className=\"flex h-full w-full flex-col\">{children}</div>\n          </SheetContent>\n        </Sheet>\n      )\n    }\n\n    return (\n      <div\n        ref={ref}\n        className=\"group peer hidden md:block text-sidebar-foreground\"\n        data-state={state}\n        data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n        data-variant={variant}\n        data-side={side}\n      >\n        {/* This is what handles the sidebar gap on desktop */}\n        <div\n          className={cn(\n            \"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear\",\n            \"group-data-[collapsible=offcanvas]:w-0\",\n            \"group-data-[side=right]:rotate-180\",\n            variant === \"floating\" || variant === \"inset\"\n              ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon]\"\n          )}\n        />\n        <div\n          className={cn(\n            \"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex\",\n            side === \"left\"\n              ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n              : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n            // Adjust the padding for floating and inset variants.\n            variant === \"floating\" || variant === \"inset\"\n              ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n            className\n          )}\n          {...props}\n        >\n          <div\n            data-sidebar=\"sidebar\"\n            className=\"flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow\"\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    )\n  }\n)\nSidebar.displayName = \"Sidebar\"\n\nconst SidebarTrigger = React.forwardRef<\n  React.ElementRef<typeof Button>,\n  React.ComponentProps<typeof Button>\n>(({ className, onClick, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <Button\n      ref={ref}\n      data-sidebar=\"trigger\"\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn(\"h-7 w-7\", className)}\n      onClick={(event) => {\n        onClick?.(event)\n        toggleSidebar()\n      }}\n      {...props}\n    >\n      <PanelLeft />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  )\n})\nSidebarTrigger.displayName = \"SidebarTrigger\"\n\nconst SidebarRail = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\">\n>(({ className, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar()\n\n  return (\n    <button\n      ref={ref}\n      data-sidebar=\"rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex\",\n        \"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarRail.displayName = \"SidebarRail\"\n\nconst SidebarInset = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"main\">\n>(({ className, ...props }, ref) => {\n  return (\n    <main\n      ref={ref}\n      className={cn(\n        \"relative flex min-h-svh flex-1 flex-col bg-background\",\n        \"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarInset.displayName = \"SidebarInset\"\n\nconst SidebarInput = React.forwardRef<\n  React.ElementRef<typeof Input>,\n  React.ComponentProps<typeof Input>\n>(({ className, ...props }, ref) => {\n  return (\n    <Input\n      ref={ref}\n      data-sidebar=\"input\"\n      className={cn(\n        \"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarInput.displayName = \"SidebarInput\"\n\nconst SidebarHeader = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarHeader.displayName = \"SidebarHeader\"\n\nconst SidebarFooter = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarFooter.displayName = \"SidebarFooter\"\n\nconst SidebarSeparator = React.forwardRef<\n  React.ElementRef<typeof Separator>,\n  React.ComponentProps<typeof Separator>\n>(({ className, ...props }, ref) => {\n  return (\n    <Separator\n      ref={ref}\n      data-sidebar=\"separator\"\n      className={cn(\"mx-2 w-auto bg-sidebar-border\", className)}\n      {...props}\n    />\n  )\n})\nSidebarSeparator.displayName = \"SidebarSeparator\"\n\nconst SidebarContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarContent.displayName = \"SidebarContent\"\n\nconst SidebarGroup = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  )\n})\nSidebarGroup.displayName = \"SidebarGroup\"\n\nconst SidebarGroupLabel = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"div\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarGroupLabel.displayName = \"SidebarGroupLabel\"\n\nconst SidebarGroupAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarGroupAction.displayName = \"SidebarGroupAction\"\n\nconst SidebarGroupContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"group-content\"\n    className={cn(\"w-full text-sm\", className)}\n    {...props}\n  />\n))\nSidebarGroupContent.displayName = \"SidebarGroupContent\"\n\nconst SidebarMenu = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu\"\n    className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n    {...props}\n  />\n))\nSidebarMenu.displayName = \"SidebarMenu\"\n\nconst SidebarMenuItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    data-sidebar=\"menu-item\"\n    className={cn(\"group/menu-item relative\", className)}\n    {...props}\n  />\n))\nSidebarMenuItem.displayName = \"SidebarMenuItem\"\n\nconst sidebarMenuButtonVariants = cva(\n  `peer/menu-button\n  flex\n  w-full\n  items-center\n  gap-2\n  overflow-hidden\n  rounded-md\n  p-2\n  text-left\n  text-sm\n  outline-none\n  ring-sidebar-ring\n  transition-[width,height,padding]\n  hover:bg-sidebar-accent\n  hover:text-sidebar-accent-foreground\n  focus-visible:ring-2\n  active:bg-sidebar-accent\n  active:text-sidebar-accent-foreground\n  disabled:pointer-events-none\n  disabled:opacity-50\n  group-has-[[data-sidebar=menu-action]]/menu-item:pr-8\n  aria-disabled:pointer-events-none\n  aria-disabled:opacity-50\n  data-[active=true]:bg-foreground\n  data-[active=true]:font-medium\n  data-[active=true]:text-background\n  data-[state=open]:hover:bg-sidebar-accent\n  data-[state=open]:hover:text-sidebar-accent-foreground\n  group-data-[collapsible=icon]:!size-8\n  group-data-[collapsible=icon]:!p-2\n  [&>span:last-child]:truncate\n  [&>svg]:size-4\n  [&>svg]:shrink-0`,\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-8 text-xs\",\n        lg: \"h-8 text-sm group-data-[collapsible=icon]:!p-0\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nconst SidebarMenuButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean\n    isActive?: boolean\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>\n  } & VariantProps<typeof sidebarMenuButtonVariants>\n>(\n  (\n    {\n      asChild = false,\n      isActive = false,\n      variant = \"default\",\n      size = \"default\",\n      tooltip,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const Comp = asChild ? Slot : \"button\"\n    const { isMobile, state } = useSidebar()\n\n    const button = (\n      <Comp\n        ref={ref}\n        data-sidebar=\"menu-button\"\n        data-size={size}\n        data-active={isActive}\n        className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n        {...props}\n      />\n    )\n\n    if (!tooltip) {\n      return button\n    }\n\n    if (typeof tooltip === \"string\") {\n      tooltip = {\n        children: tooltip,\n      }\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>{button}</TooltipTrigger>\n        <TooltipContent\n          side=\"right\"\n          align=\"center\"\n          hidden={state !== \"collapsed\" || isMobile}\n          {...tooltip}\n        />\n      </Tooltip>\n    )\n  }\n)\nSidebarMenuButton.displayName = \"SidebarMenuButton\"\n\nconst SidebarMenuAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean\n    showOnHover?: boolean\n  }\n>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarMenuAction.displayName = \"SidebarMenuAction\"\n\nconst SidebarMenuBadge = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"menu-badge\"\n    className={cn(\n      \"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none\",\n      \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n      \"peer-data-[size=sm]/menu-button:top-1\",\n      \"peer-data-[size=default]/menu-button:top-1.5\",\n      \"peer-data-[size=lg]/menu-button:top-2.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className\n    )}\n    {...props}\n  />\n))\nSidebarMenuBadge.displayName = \"SidebarMenuBadge\"\n\nconst SidebarMenuSkeleton = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    showIcon?: boolean\n  }\n>(({ className, showIcon = false, ...props }, ref) => {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`\n  }, [])\n\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"rounded-md h-8 flex gap-2 px-2 items-center\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 flex-1 max-w-[--skeleton-width]\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  )\n})\nSidebarMenuSkeleton.displayName = \"SidebarMenuSkeleton\"\n\nconst SidebarMenuSub = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu-sub\"\n    className={cn(\n      \"flex min-w-0 translate-x-px flex-col gap-1 pl-2 py-0.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className\n    )}\n    {...props}\n  />\n))\nSidebarMenuSub.displayName = \"SidebarMenuSub\"\n\nconst SidebarMenuSubItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ ...props }, ref) => <li ref={ref} {...props} />)\nSidebarMenuSubItem.displayName = \"SidebarMenuSubItem\"\n\nconst SidebarMenuSubButton = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentProps<\"a\"> & {\n    asChild?: boolean\n    size?: \"sm\" | \"md\"\n    isActive?: boolean\n  }\n>(({ asChild = false, size = \"md\", isActive, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"a\"\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n})\nSidebarMenuSubButton.displayName = \"SidebarMenuSubButton\"\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n}\n"
  },
  {
    "path": "src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\"\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\"animate-pulse rounded-md bg-primary/10\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "src/components/ui/slider.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SliderPrimitive from \"@radix-ui/react-slider\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Slider = React.forwardRef<\n  React.ElementRef<typeof SliderPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <SliderPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative flex w-full touch-none select-none items-center\",\n      className\n    )}\n    {...props}\n  >\n    <SliderPrimitive.Track className=\"relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20\">\n      <SliderPrimitive.Range className=\"absolute h-full bg-primary\" />\n    </SliderPrimitive.Track>\n    <SliderPrimitive.Thumb className=\"block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\" />\n  </SliderPrimitive.Root>\n))\nSlider.displayName = SliderPrimitive.Root.displayName\n\nexport { Slider }\n"
  },
  {
    "path": "src/components/ui/swipe-back.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useRef, useCallback } from 'react'\nimport { useRouter } from 'next/navigation'\n\ninterface SwipeBackProps {\n  children: React.ReactNode\n  edgeWidth?: number // 左侧边缘触发区域宽度（百分比）\n  threshold?: number // 触发返回的滑动距离阈值（像素）\n}\n\nexport function SwipeBack({\n  children,\n  edgeWidth = 15,\n  threshold = 80\n}: SwipeBackProps) {\n  const router = useRouter()\n  const [canGoBack, setCanGoBack] = useState(false)\n\n  const touchStartX = useRef<number | null>(null)\n  const touchStartY = useRef<number | null>(null)\n  const isDragging = useRef(false)\n\n  // 检查是否可以返回\n  useEffect(() => {\n    if (typeof window !== 'undefined') {\n      setCanGoBack(window.history.length > 1)\n    }\n  }, [])\n\n  const handleTouchStart = useCallback((e: TouchEvent) => {\n    const touch = e.touches[0]\n    const screenWidth = window.innerWidth\n    const touchX = touch.clientX\n\n    // 只在左侧边缘区域响应\n    if (touchX < screenWidth * (edgeWidth / 100)) {\n      touchStartX.current = touch.clientX\n      touchStartY.current = touch.clientY\n      isDragging.current = true\n    }\n  }, [edgeWidth])\n\n  const handleTouchMove = useCallback((e: TouchEvent) => {\n    if (!isDragging.current || touchStartX.current === null) return\n\n    const touch = e.touches[0]\n    const deltaX = touch.clientX - touchStartX.current\n    const deltaY = Math.abs(touch.clientY - (touchStartY.current || 0))\n\n    // 如果是向右滑动且水平位移大于垂直位移\n    if (deltaX > 0 && deltaX > deltaY) {\n      // 阻止默认滚动行为\n      e.preventDefault()\n    }\n  }, [])\n\n  const handleTouchEnd = useCallback((e: TouchEvent) => {\n    if (!isDragging.current || touchStartX.current === null) {\n      isDragging.current = false\n      return\n    }\n\n    const touch = e.changedTouches[0]\n    const deltaX = touch.clientX - touchStartX.current\n    const deltaY = Math.abs(touch.clientY - (touchStartY.current || 0))\n\n    // 如果向右滑动超过阈值，且水平位移大于垂直位移\n    if (deltaX > threshold && deltaX > deltaY) {\n      router.back()\n    }\n\n    touchStartX.current = null\n    touchStartY.current = null\n    isDragging.current = false\n  }, [router, threshold])\n\n  useEffect(() => {\n    if (!canGoBack) return\n\n    const container = document.body\n\n    container.addEventListener('touchstart', handleTouchStart, { passive: false })\n    container.addEventListener('touchmove', handleTouchMove, { passive: false })\n    container.addEventListener('touchend', handleTouchEnd, { passive: false })\n\n    return () => {\n      container.removeEventListener('touchstart', handleTouchStart)\n      container.removeEventListener('touchmove', handleTouchMove)\n      container.removeEventListener('touchend', handleTouchEnd)\n    }\n  }, [canGoBack, handleTouchStart, handleTouchMove, handleTouchEnd])\n\n  if (!canGoBack) {\n    return <>{children}</>\n  }\n\n  return <>{children}</>\n}\n"
  },
  {
    "path": "src/components/ui/switch.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      \"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input\",\n      className\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        \"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0\"\n      )}\n    />\n  </SwitchPrimitives.Root>\n))\nSwitch.displayName = SwitchPrimitives.Root.displayName\n\nexport { Switch }\n"
  },
  {
    "path": "src/components/ui/table.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <div className=\"relative w-full overflow-auto\">\n    <table\n      ref={ref}\n      className={cn(\"w-full caption-bottom text-sm\", className)}\n      {...props}\n    />\n  </div>\n))\nTable.displayName = \"Table\"\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn(\"[&_tr]:border-b\", className)} {...props} />\n))\nTableHeader.displayName = \"TableHeader\"\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n))\nTableBody.displayName = \"TableBody\"\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\n      \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\",\n      className\n    )}\n    {...props}\n  />\n))\nTableFooter.displayName = \"TableFooter\"\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\",\n      className\n    )}\n    {...props}\n  />\n))\nTableRow.displayName = \"TableRow\"\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableHead.displayName = \"TableHead\"\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\n      \"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\",\n      className\n    )}\n    {...props}\n  />\n))\nTableCell.displayName = \"TableCell\"\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n))\nTableCaption.displayName = \"TableCaption\"\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}\n"
  },
  {
    "path": "src/components/ui/tabs.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Tabs = TabsPrimitive.Root\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsList.displayName = TabsPrimitive.List.displayName\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTabsContent.displayName = TabsPrimitive.Content.displayName\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaElement,\n  React.ComponentProps<\"textarea\">\n>(({ className, ...props }, ref) => {\n  return (\n    <textarea\n      className={cn(\n        \"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      ref={ref}\n      {...props}\n    />\n  )\n})\nTextarea.displayName = \"Textarea\"\n\nexport { Textarea }\n"
  },
  {
    "path": "src/components/ui/toast.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ToastProvider = ToastPrimitives.Provider\n\nconst ToastViewport = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Viewport>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Viewport\n    ref={ref}\n    className={cn(\n      \"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]\",\n      className\n    )}\n    {...props}\n  />\n))\nToastViewport.displayName = ToastPrimitives.Viewport.displayName\n\nconst toastVariants = cva(\n  \"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full\",\n  {\n    variants: {\n      variant: {\n        default: \"border bg-background text-foreground\",\n        destructive:\n          \"destructive group border-destructive bg-destructive text-destructive-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nconst Toast = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &\n    VariantProps<typeof toastVariants>\n>(({ className, variant, ...props }, ref) => {\n  return (\n    <ToastPrimitives.Root\n      ref={ref}\n      className={cn(toastVariants({ variant }), className)}\n      {...props}\n    />\n  )\n})\nToast.displayName = ToastPrimitives.Root.displayName\n\nconst ToastAction = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Action>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Action\n    ref={ref}\n    className={cn(\n      \"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive\",\n      className\n    )}\n    {...props}\n  />\n))\nToastAction.displayName = ToastPrimitives.Action.displayName\n\nconst ToastClose = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Close>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Close\n    ref={ref}\n    className={cn(\n      \"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600\",\n      className\n    )}\n    toast-close=\"\"\n    {...props}\n  >\n    <X className=\"h-4 w-4\" />\n  </ToastPrimitives.Close>\n))\nToastClose.displayName = ToastPrimitives.Close.displayName\n\nconst ToastTitle = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Title>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Title\n    ref={ref}\n    className={cn(\"text-sm font-semibold [&+div]:text-xs\", className)}\n    {...props}\n  />\n))\nToastTitle.displayName = ToastPrimitives.Title.displayName\n\nconst ToastDescription = React.forwardRef<\n  React.ElementRef<typeof ToastPrimitives.Description>,\n  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>\n>(({ className, ...props }, ref) => (\n  <ToastPrimitives.Description\n    ref={ref}\n    className={cn(\"text-sm opacity-90\", className)}\n    {...props}\n  />\n))\nToastDescription.displayName = ToastPrimitives.Description.displayName\n\ntype ToastProps = React.ComponentPropsWithoutRef<typeof Toast>\n\ntype ToastActionElement = React.ReactElement<typeof ToastAction>\n\nexport {\n  type ToastProps,\n  type ToastActionElement,\n  ToastProvider,\n  ToastViewport,\n  Toast,\n  ToastTitle,\n  ToastDescription,\n  ToastClose,\n  ToastAction,\n}\n"
  },
  {
    "path": "src/components/ui/toaster.tsx",
    "content": "\"use client\"\n\nimport { useToast } from \"@/hooks/use-toast\"\nimport {\n  Toast,\n  ToastClose,\n  ToastDescription,\n  ToastProvider,\n  ToastTitle,\n  ToastViewport,\n} from \"@/components/ui/toast\"\n\nexport function Toaster() {\n  const { toasts } = useToast()\n\n  return (\n    <ToastProvider>\n      {toasts.map(function ({ id, title, description, action, ...props }) {\n        return (\n          <Toast key={id} {...props}>\n            <div className=\"grid gap-1\">\n              {title && <ToastTitle>{title}</ToastTitle>}\n              {description && (\n                <ToastDescription>{description}</ToastDescription>\n              )}\n            </div>\n            {action}\n            <ToastClose />\n          </Toast>\n        )\n      })}\n      <ToastViewport />\n    </ToastProvider>\n  )\n}\n"
  },
  {
    "path": "src/components/ui/toggle.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TogglePrimitive from \"@radix-ui/react-toggle\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst toggleVariants = cva(\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline:\n          \"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground\",\n      },\n      size: {\n        default: \"h-9 px-2 min-w-9\",\n        sm: \"h-8 px-1.5 min-w-8\",\n        lg: \"h-10 px-2.5 min-w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nconst Toggle = React.forwardRef<\n  React.ElementRef<typeof TogglePrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &\n    VariantProps<typeof toggleVariants>\n>(({ className, variant, size, ...props }, ref) => (\n  <TogglePrimitive.Root\n    ref={ref}\n    className={cn(toggleVariants({ variant, size, className }))}\n    {...props}\n  />\n))\n\nToggle.displayName = TogglePrimitive.Root.displayName\n\nexport { Toggle, toggleVariants }\n"
  },
  {
    "path": "src/components/ui/tooltip.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Portal>\n    <TooltipPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </TooltipPrimitive.Portal>\n))\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "src/config/emitters.ts",
    "content": "export enum EmitterShortcutEvents {\n  screenshot = \"screenshot-shortcut-register\",\n  text = \"text-shortcut-register\",\n  pin = \"window-pin-register\",\n  link = \"link-shortcut-register\"\n}\n\nexport enum EmitterRecordEvents {\n  refreshMarks = \"refresh-marks\"\n}"
  },
  {
    "path": "src/config/shortcut.ts",
    "content": "export enum ShortcutSettings {\n  screenshot = \"shotcut-screenshot\",\n  text = \"shotcut-text\",\n  pin = \"window-pin\",\n  link = \"shotcut-link\"\n}\n\nexport enum ShortcutDefault {\n  screenshot = \"Control+Shift+S\",\n  text = \"Control+Shift+T\",\n  pin = \"Control+Shift+P\",\n  link = \"Control+Shift+L\",\n}\n\n/**\n * 文件管理器快捷键\n * rename: F2 (Win/Linux) / Enter (macOS) - 重命名选中的文件或文件夹（仅桌面端）\n * copy: Ctrl+C (Win/Linux) / Cmd+C (macOS) - 复制选中的文件或文件夹\n * paste: Ctrl+V (Win/Linux) / Cmd+V (macOS) - 粘贴剪贴板中的文件或文件夹\n * cut: Ctrl+X (Win/Linux) / Cmd+X (macOS) - 剪切选中的文件或文件夹\n * delete: Delete (Win/Linux) / Backspace (macOS) - 删除选中的文件或文件夹\n */\nexport const FileShortcuts = {\n  rename: 'F2',\n  copy: 'Ctrl+C',\n  paste: 'Ctrl+V',\n  cut: 'Ctrl+X',\n  delete: 'Delete'\n} as const"
  },
  {
    "path": "src/config/sync-exclusions.ts",
    "content": "// 同步排除配置\n\n// ==================== 文件同步排除规则 ====================\n\nexport interface SyncExcludePattern {\n  pattern: string\n  description: string\n}\n\n// 默认排除规则\nexport const DEFAULT_SYNC_EXCLUDE_PATTERNS: SyncExcludePattern[] = [\n  { pattern: '.notegen/', description: '应用配置目录' },\n  { pattern: '*.tmp', description: '临时文件' },\n  { pattern: '*.bak', description: '备份文件' },\n  { pattern: '*.swp', description: '编辑器临时文件' },\n  { pattern: 'Thumbs.db', description: 'Windows 缩略图' },\n  { pattern: '.DS_Store', description: 'macOS 系统文件' },\n  { pattern: '*.lock', description: '锁定文件' },\n]\n\n// 检查路径是否应该排除在同步之外\nexport function shouldExclude(path: string): boolean {\n  const excludePatterns = getExcludePatterns()\n\n  for (const pattern of excludePatterns) {\n    if (matchPattern(pattern, path)) {\n      return true\n    }\n  }\n\n  return false\n}\n\n// 通配符匹配\nfunction matchPattern(pattern: string, path: string): boolean {\n  // 目录模式（以 / 结尾）\n  if (pattern.endsWith('/')) {\n    return path.startsWith(pattern)\n  }\n\n  // 文件名模式\n  if (pattern.startsWith('*.')) {\n    const ext = pattern.slice(1) // *.tmp -> .tmp\n    return path.endsWith(ext) || path.includes(`.tmp${ext}`) // 处理 .tmp.txt 的情况\n  }\n\n  // 简单字符串匹配\n  return path === pattern || path.includes(pattern)\n}\n\n// 获取排除模式（从配置读取或使用默认值）\nexport function getExcludePatterns(): string[] {\n  // TODO: 从配置读取用户自定义的排除规则\n  return DEFAULT_SYNC_EXCLUDE_PATTERNS.map(p => p.pattern)\n}\n\n// ==================== 设置同步排除规则 ====================\n\nexport const SYNC_EXCLUDED_FIELDS: string[] = [\n  'workspacePath',\n  'workspaceHistory',\n  'assetsPath',\n  'uiScale',\n  'contentTextScale',\n  'customCss',\n]\n\n// 检查字段是否应该被排除在同步之外\nexport function shouldExcludeFromSync(fieldName: string): boolean {\n  return SYNC_EXCLUDED_FIELDS.includes(fieldName)\n}\n\n// 从对象中过滤掉不应该同步的字段\nexport function filterSyncData<T extends Record<string, any>>(data: T): Partial<T> {\n  const filtered: Partial<T> = {}\n  \n  for (const key in data) {\n    if (!shouldExcludeFromSync(key)) {\n      filtered[key] = data[key]\n    }\n  }\n  \n  return filtered\n}\n\n// 合并下载的配置数据，保留本地的排除字段\nexport function mergeSyncData<T extends Record<string, any>>(\n  localData: T,\n  remoteData: Partial<T>\n): T {\n  const merged = { ...remoteData } as T\n  \n  // 保留本地的排除字段\n  for (const field of SYNC_EXCLUDED_FIELDS) {\n    if (field in localData) {\n      merged[field as keyof T] = localData[field as keyof T]\n    }\n  }\n  \n  return merged\n}\n"
  },
  {
    "path": "src/contexts/text-size-context.tsx",
    "content": "'use client'\n\nimport React, { createContext, useContext, ReactNode } from 'react'\nimport useSettingStore from '@/stores/setting'\n\ninterface TextSizeContextType {\n  fileManagerTextSize: string\n  recordTextSize: string\n  getContextMenuTextSize: (type: 'file' | 'record') => string\n  getIconSize: (textSize: string, type: 'file' | 'record') => string\n}\n\nconst TextSizeContext = createContext<TextSizeContextType | undefined>(undefined)\n\nexport function useTextSize() {\n  const context = useContext(TextSizeContext)\n  if (!context) {\n    throw new Error('useTextSize must be used within a TextSizeProvider')\n  }\n  return context\n}\n\ninterface TextSizeProviderProps {\n  children: ReactNode\n}\n\nexport function TextSizeProvider({ children }: TextSizeProviderProps) {\n  const { fileManagerTextSize, recordTextSize } = useSettingStore()\n\n  const getContextMenuTextSize = (type: 'file' | 'record') => {\n    return type === 'file' ? fileManagerTextSize : recordTextSize\n  }\n\n  const getIconSize = (textSize: string, type: 'file' | 'record') => {\n    if (type === 'file') {\n      const sizeMap = {\n        'xs': 'size-3',\n        'sm': 'size-3.5', \n        'md': 'size-4',\n        'lg': 'size-5',\n        'xl': 'size-6'\n      }\n      return sizeMap[textSize as keyof typeof sizeMap] || 'size-4'\n    } else {\n      const sizeMap = {\n        'xs': 'size-2',\n        'sm': 'size-2.5', \n        'md': 'size-3',\n        'lg': 'size-3.5',\n        'xl': 'size-4'\n      }\n      return sizeMap[textSize as keyof typeof sizeMap] || 'size-3'\n    }\n  }\n\n  const value = {\n    fileManagerTextSize,\n    recordTextSize,\n    getContextMenuTextSize,\n    getIconSize\n  }\n\n  return (\n    <TextSizeContext.Provider value={value}>\n      {children}\n    </TextSizeContext.Provider>\n  )\n}\n"
  },
  {
    "path": "src/db/activity.ts",
    "content": "import { getDb } from './index'\nimport { getAllChats } from './chats'\nimport { getAllMarks } from './marks'\nimport { getAllMarkdownFiles } from '@/lib/files'\nimport { shouldCreateWritingSession, truncateActivityText } from '@/lib/activity/events'\n\nexport type ActivityEventSource = 'record' | 'chat' | 'writing'\n\nexport interface ActivityEvent {\n  id: number\n  source: ActivityEventSource\n  title: string\n  description?: string | null\n  path?: string | null\n  tagId?: number | null\n  dedupeKey?: string | null\n  createdAt: number\n}\n\ninterface InsertActivityEventInput {\n  source: ActivityEventSource\n  title: string\n  description?: string | null\n  path?: string | null\n  tagId?: number | null\n  dedupeKey?: string | null\n  createdAt?: number\n}\n\nexport async function initActivityDb() {\n  const db = await getDb()\n\n  await db.execute(`\n    create table if not exists activity_events (\n      id integer primary key autoincrement,\n      source text not null,\n      title text not null,\n      description text default null,\n      path text default null,\n      tagId integer default null,\n      dedupeKey text default null,\n      createdAt integer not null\n    )\n  `)\n\n  try {\n    await db.execute(`\n      create unique index if not exists idx_activity_events_dedupe\n      on activity_events(dedupeKey)\n      where dedupeKey is not null\n    `)\n  } catch {\n  }\n\n  await db.execute(`\n    create index if not exists idx_activity_events_created_at\n    on activity_events(createdAt desc)\n  `)\n\n  await db.execute(`\n    create index if not exists idx_activity_events_source_path_created_at\n    on activity_events(source, path, createdAt desc)\n  `)\n\n  await backfillActivityEvents()\n}\n\nexport async function insertActivityEvent(event: InsertActivityEventInput) {\n  const db = await getDb()\n  const createdAt = event.createdAt ?? Date.now()\n\n  return await db.execute(\n    `insert or ignore into activity_events\n      (source, title, description, path, tagId, dedupeKey, createdAt)\n     values ($1, $2, $3, $4, $5, $6, $7)`,\n    [\n      event.source,\n      event.title,\n      event.description ?? null,\n      event.path ?? null,\n      event.tagId ?? null,\n      event.dedupeKey ?? null,\n      createdAt,\n    ]\n  )\n}\n\nexport async function getAllActivityEvents() {\n  const db = await getDb()\n  return await db.select<ActivityEvent[]>(`\n    select id, source, title, description, path, tagId, dedupeKey, createdAt\n    from activity_events\n    order by createdAt desc\n  `)\n}\n\nasync function getLatestWritingEventTimestamp(path: string) {\n  const db = await getDb()\n  const result = await db.select<{ createdAt: number }[]>(\n    `select createdAt from activity_events\n     where source = 'writing' and path = $1\n     order by createdAt desc\n     limit 1`,\n    [path]\n  )\n\n  return result[0]?.createdAt\n}\n\nexport async function recordWritingActivity(params: {\n  path: string\n  title: string\n  description?: string\n  tagId?: number | null\n  createdAt?: number\n}) {\n  const createdAt = params.createdAt ?? Date.now()\n  const lastCreatedAt = await getLatestWritingEventTimestamp(params.path)\n\n  if (!shouldCreateWritingSession(lastCreatedAt, createdAt)) {\n    return null\n  }\n\n  return await insertActivityEvent({\n    source: 'writing',\n    title: truncateActivityText(params.title, 64),\n    description: truncateActivityText(params.description ?? params.path, 140),\n    path: params.path,\n    tagId: params.tagId ?? null,\n    dedupeKey: `writing:${params.path}:${createdAt}`,\n    createdAt,\n  })\n}\n\nasync function backfillActivityEvents() {\n  const [marks, chats, files] = await Promise.all([\n    getAllMarks(),\n    getAllChats(),\n    getAllMarkdownFiles(true),\n  ])\n\n  for (const mark of marks) {\n    if (mark.deleted === 1) continue\n\n    const preview = truncateActivityText(mark.desc || mark.content || mark.url || '', 140)\n\n    await insertActivityEvent({\n      source: 'record',\n      title: preview || mark.type,\n      description: preview || mark.type,\n      tagId: mark.tagId,\n      dedupeKey: `record:${mark.id}`,\n      createdAt: mark.createdAt,\n    })\n  }\n\n  for (const chat of chats) {\n    if (chat.role !== 'user' || !chat.content?.trim()) continue\n\n    const preview = truncateActivityText(chat.content, 140)\n\n    await insertActivityEvent({\n      source: 'chat',\n      title: truncateActivityText(chat.content, 64),\n      description: preview,\n      tagId: chat.tagId,\n      dedupeKey: `chat:${chat.id}`,\n      createdAt: chat.createdAt,\n    })\n  }\n\n  for (const file of files) {\n    const modifiedAt = file.metadata?.modifiedAt?.getTime()\n    if (!modifiedAt) continue\n\n    await insertActivityEvent({\n      source: 'writing',\n      title: truncateActivityText(file.name, 64),\n      description: truncateActivityText(file.relativePath, 140),\n      path: file.relativePath,\n      dedupeKey: `writing-backfill:${file.relativePath}:${modifiedAt}`,\n      createdAt: modifiedAt,\n    })\n  }\n}\n"
  },
  {
    "path": "src/db/chats.ts",
    "content": "import { getDb } from \"./index\"\nimport { insertActivityEvent } from './activity'\nimport { truncateActivityText } from '@/lib/activity/events'\n\nexport type Role = 'system' | 'user'\nexport type ChatType = 'chat' | 'note' | 'clipboard' | 'clear' | 'condensed'\n\nexport interface Chat {\n  id: number\n  tagId?: number // 可选，用于兼容过渡期\n  conversationId?: number // 关联的会话 ID\n  content?: string\n  role: Role\n  type: ChatType\n  image?: string\n  images?: string // 多张图片，JSON字符串数组\n  inserted: boolean // 是否插入到 mark 中\n  createdAt: number\n  ragSources?: string // RAG引用的文件名，JSON字符串数组\n  ragSourceDetails?: string // RAG引用的详细信息，JSON字符串数组（包含文件路径和文本片段）\n  agentHistory?: string // Agent执行历史，JSON字符串\n  thinking?: string // AI 思考过程\n  quoteData?: string // 引用信息，JSON字符串\n  // 压缩相关字段\n  condensedContent?: string    // 压缩后的摘要内容（存储在本条消息上）\n  condensedAt?: number         // 压缩时间戳\n}\n\n// 创建 chats 表\nexport async function initChatsDb() {\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists chats (\n      id integer primary key autoincrement,\n      tagId integer not null,\n      content text default null,\n      role text not null,\n      type text not null,\n      image text default null,\n      images text default null,\n      inserted boolean default false,\n      createdAt integer not null,\n      ragSources text default null,\n      agentHistory text default null,\n      thinking text default null,\n      quoteData text default null\n    )\n  `)\n  \n  // 迁移：为现有表添加 ragSources 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column ragSources text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n    // SQLite 会抛出 \"duplicate column name\" 错误\n  }\n  \n  // 迁移：为现有表添加 agentHistory 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column agentHistory text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n  \n  // 迁移：为现有表添加 images 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column images text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n  \n  // 迁移：为现有表添加 thinking 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column thinking text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n  \n  // 迁移：为现有表添加 quoteData 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column quoteData text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 ragSourceDetails 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column ragSourceDetails text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 condensedFrom 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column condensedFrom text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 originalTokenCount 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column originalTokenCount integer default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 originalMessageCount 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column originalMessageCount integer default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 condensedAt 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column condensedAt integer default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 condensedContent 列（如果不存在）\n  try {\n    await db.execute(`\n      alter table chats add column condensedContent text default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移：为现有表添加 conversationId 列（如果不存在）\n  // 注意：这个迁移已移到 conversations.ts 的 initConversationsDb 中执行\n  // 这里保留是为了向后兼容，如果 conversations 初始化失败，这里会确保列存在\n  try {\n    await db.execute(`\n      alter table chats add column conversationId integer default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n}\n\n// 插入一条 chat\nexport async function insertChat(chat: Omit<Chat, 'id' | 'createdAt'>) {\n  const db = await getDb()\n  const createdAt = Date.now();\n  const result = await db.execute(\n    \"insert into chats (tagId, conversationId, content, role, type, image, images, inserted, createdAt, ragSources, ragSourceDetails, agentHistory, thinking, quoteData, condensedContent, condensedAt) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\",\n    [chat.tagId, chat.conversationId, chat.content, chat.role, chat.type, chat.image, chat.images, chat.inserted ? 1 : 0, createdAt, chat.ragSources, chat.ragSourceDetails, chat.agentHistory, chat.thinking, chat.quoteData, chat.condensedContent, chat.condensedAt]\n  )\n\n  if (chat.role === 'user' && chat.content?.trim()) {\n    await insertActivityEvent({\n      source: 'chat',\n      title: truncateActivityText(chat.content, 64),\n      description: truncateActivityText(chat.content, 140),\n      tagId: chat.tagId ?? null,\n      dedupeKey: result.lastInsertId ? `chat:${result.lastInsertId}` : `chat:${createdAt}`,\n      createdAt,\n    })\n  }\n\n  return result\n}\n\n// 获取所有 chats\nexport async function getChats(tagId: number) {\n  const db = await getDb()\n  const result = await db.select<Chat[]>(\n    \"select * from chats where tagId = $1 order by createdAt\",\n    [tagId]\n  )\n  return result\n}\n\n// 根据会话 ID 获取聊天记录（新方式）\nexport async function getChatsByConversation(conversationId: number) {\n  const db = await getDb()\n  const result = await db.select<Chat[]>(\n    \"select * from chats where conversationId = $1 order by createdAt\",\n    [conversationId]\n  )\n  return result\n}\n\n// 获取所有 chats（用于同步）\nexport async function getAllChats() {\n  const db = await getDb()\n  const result = await db.select<Chat[]>(\n    \"select * from chats order by createdAt\",\n    []\n  )\n  return result\n}\n\n// 插入多条 chat（用于同步）\nexport async function insertChats(chats: Chat[]) {\n  const db = await getDb()\n\n  await db.execute('BEGIN TRANSACTION')\n  try {\n    for (const chat of chats) {\n      await db.execute(\n        \"insert into chats (tagId, content, role, type, image, images, inserted, createdAt, ragSources) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)\",\n        [chat.tagId, chat.content, chat.role, chat.type, chat.image, chat.images, chat.inserted ? 1 : 0, chat.createdAt, chat.ragSources]\n      )\n    }\n    await db.execute('COMMIT')\n  } catch (error) {\n    await db.execute('ROLLBACK')\n    throw error\n  }\n}\n\n// 删除所有 chats（用于同步）\nexport async function deleteAllChats() {\n  const db = await getDb()\n  return await db.execute(\n    \"delete from chats\",\n    []\n  )\n}\n\n// 更新一条 chat\nexport async function updateChat(chat: Chat) {\n  const db = await getDb()\n  return await db.execute(\n    \"update chats set tagId = $1, conversationId = $2, content = $3, role = $4, type = $5, image = $6, images = $7, inserted = $8, ragSources = $9, ragSourceDetails = $10, agentHistory = $11, thinking = $12, quoteData = $13, condensedContent = $14, condensedAt = $15 where id = $16\",\n    [chat.tagId, chat.conversationId, chat.content, chat.role, chat.type, chat.image, chat.images, chat.inserted ? 1 : 0, chat.ragSources, chat.ragSourceDetails, chat.agentHistory, chat.thinking, chat.quoteData, chat.condensedContent, chat.condensedAt, chat.id])\n}\n\n// 清空 tagId 下的所有 chats\nexport async function clearChatsByTagId(tagId: number) {\n  const db = await getDb()\n  return await db.execute(\n    \"delete from chats where tagId = $1\",\n    [tagId])\n}\n\n// 已插入\nexport async function updateChatsInsertedById(id: number) {\n  const db = await getDb()\n  return await db.execute(\n    \"update chats set inserted = $1 where id = $2\",\n    [true, id])\n}\n\n// 删除一条 chat\nexport async function deleteChat(id: number) {\n  const db = await getDb()\n  return await db.execute(\n    \"delete from chats where id = $1\",\n    [id])\n}\n\nexport async function updateChats(chats: Chat[]) {\n  const db = await getDb()\n  try {\n    for (const chat of chats) {\n      await db.execute(\n        \"update chats set tagId = $1, conversationId = $2, content = $3, role = $4, type = $5, image = $6, images = $7, inserted = $8, ragSources = $9, ragSourceDetails = $10, agentHistory = $11, thinking = $12, quoteData = $13, condensedContent = $14, condensedAt = $15 where id = $16\",\n        [chat.tagId, chat.conversationId, chat.content, chat.role, chat.type, chat.image, chat.images, chat.inserted ? 1 : 0, chat.ragSources, chat.ragSourceDetails, chat.agentHistory, chat.thinking, chat.quoteData, chat.condensedContent, chat.condensedAt, chat.id]\n      )\n    }\n  } catch (error) {\n    console.error('Error updating chats:', error);\n    throw error;\n  }\n}\n\nexport async function deleteChats(ids: number[]) {\n  const db = await getDb()\n  try {\n    for (const id of ids) {\n      await db.execute(\n        \"delete from chats where id = $1\",\n        [id]\n      )\n    }\n  } catch (error) {\n    console.error('Error deleting chats:', error);\n    throw error;\n  }\n}\n\n/**\n * 更新消息的压缩摘要内容\n * @param chatId 消息 ID\n * @param condensedContent 压缩摘要内容\n */\nexport async function updateChatCondensedContent(chatId: number, condensedContent: string) {\n  const db = await getDb()\n  try {\n    await db.execute(\n      \"update chats set condensedContent = $1, condensedAt = $2 where id = $3\",\n      [condensedContent, Date.now(), chatId]\n    )\n  } catch (error) {\n    console.error('Error updating chat condensed content:', error);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/db/conversations.ts",
    "content": "import { getDb } from \"./index\"\n\nexport interface Conversation {\n  id: number\n  title: string\n  createdAt: number\n  updatedAt: number\n  messageCount: number\n  isPinned: boolean\n}\n\n// 创建 conversations 表\nexport async function initConversationsDb() {\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists conversations (\n      id integer primary key autoincrement,\n      title text not null,\n      createdAt integer not null,\n      updatedAt integer not null,\n      messageCount integer default 0,\n      isPinned integer default 0\n    )\n  `)\n\n  // 创建索引\n  await db.execute(`\n    create index if not exists idx_conversations_created on conversations(createdAt desc)\n  `)\n  await db.execute(`\n    create index if not exists idx_conversations_updated on conversations(updatedAt desc)\n  `)\n\n  // 检查并添加 conversationId 列到 chats 表\n  try {\n    await db.execute(`\n      alter table chats add column conversationId integer default null\n    `)\n  } catch {\n    // 如果列已存在，忽略错误\n  }\n\n  // 迁移现有数据到默认会话\n  await migrateExistingChats()\n}\n\n// 迁移现有聊天记录到默认会话\nasync function migrateExistingChats() {\n  const db = await getDb()\n\n  // 获取所有现有聊天记录\n  const allChats = await db.select<{ createdAt: number }[]>(\n    \"select createdAt from chats order by createdAt\",\n    []\n  )\n\n  // 如果没有聊天记录，不需要迁移\n  if (allChats.length === 0) {\n    return\n  }\n\n  // 检查是否有聊天记录没有 conversationId\n  const chatsWithoutConversation = await db.select<{ id: number }[]>(\n    \"select id from chats where conversationId is null limit 1\",\n    []\n  )\n\n  // 如果所有聊天记录都已经有 conversationId，不需要迁移\n  if (chatsWithoutConversation.length === 0) {\n    return\n  }\n\n  // 检查是否已经有默认会话\n  const existingConversations = await db.select<Conversation[]>(\n    \"select * from conversations where title = '历史对话' limit 1\",\n    []\n  )\n\n  let defaultConversationId: number\n\n  if (existingConversations.length === 0) {\n    // 创建历史会话\n    const firstChat = allChats[0]\n    const lastChat = allChats[allChats.length - 1]\n    const result = await db.execute(\n      \"insert into conversations (title, createdAt, updatedAt, messageCount, isPinned) values ($1, $2, $3, $4, $5)\",\n      ['历史对话', firstChat.createdAt, lastChat.createdAt, allChats.length, 0]\n    )\n    defaultConversationId = result.lastInsertId as number\n\n    // 更新所有现有聊天记录的 conversationId\n    await db.execute(\n      \"update chats set conversationId = $1 where conversationId is null\",\n      [defaultConversationId]\n    )\n  } else {\n    defaultConversationId = existingConversations[0].id\n    // 更新所有没有 conversationId 的聊天记录\n    await db.execute(\n      \"update chats set conversationId = $1 where conversationId is null\",\n      [defaultConversationId]\n    )\n  }\n}\n\n// 创建新会话\nexport async function createConversation(title: string): Promise<number> {\n  const db = await getDb()\n  const now = Date.now()\n  const result = await db.execute(\n    \"insert into conversations (title, createdAt, updatedAt, messageCount, isPinned) values ($1, $2, $3, $4, $5)\",\n    [title, now, now, 0, 0]\n  )\n  return result.lastInsertId as number\n}\n\n// 获取所有会话\nexport async function getAllConversations(): Promise<Conversation[]> {\n  const db = await getDb()\n  const result = await db.select<Conversation[]>(\n    \"select * from conversations order by isPinned desc, updatedAt desc\",\n    []\n  )\n  return result\n}\n\n// 获取单个会话\nexport async function getConversation(id: number): Promise<Conversation | null> {\n  const db = await getDb()\n  const result = await db.select<Conversation[]>(\n    \"select * from conversations where id = $1\",\n    [id]\n  )\n  return result[0] || null\n}\n\n// 更新会话标题\nexport async function updateConversationTitle(id: number, title: string): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"update conversations set title = $1, updatedAt = $2 where id = $3\",\n    [title, Date.now(), id]\n  )\n}\n\n// 更新会话消息数量\nexport async function updateConversationMessageCount(id: number, delta: number): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"update conversations set messageCount = messageCount + $1, updatedAt = $2 where id = $3\",\n    [delta, Date.now(), id]\n  )\n}\n\n// 更新会话的最后更新时间\nexport async function updateConversationTime(id: number): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"update conversations set updatedAt = $1 where id = $2\",\n    [Date.now(), id]\n  )\n}\n\n// 删除会话及其相关聊天记录\nexport async function deleteConversation(id: number): Promise<void> {\n  const db = await getDb()\n  // 先删除会话的所有聊天记录\n  await db.execute(\n    \"delete from chats where conversationId = $1\",\n    [id]\n  )\n  // 再删除会话\n  await db.execute(\n    \"delete from conversations where id = $1\",\n    [id]\n  )\n}\n\n// 切换会话置顶状态\nexport async function toggleConversationPin(id: number): Promise<boolean> {\n  const db = await getDb()\n  const conv = await getConversation(id)\n  if (!conv) return false\n\n  const newPinState = conv.isPinned ? 0 : 1\n  await db.execute(\n    \"update conversations set isPinned = $1 where id = $2\",\n    [newPinState, id]\n  )\n  return !conv.isPinned\n}\n\n// 同步会话的消息数量（从实际消息重新统计）\nexport async function syncConversationMessageCount(conversationId: number): Promise<void> {\n  const db = await getDb()\n  const result = await db.select<{ count: number }[]>(\n    \"select count(*) as count from chats where conversationId = $1\",\n    [conversationId]\n  )\n  const actualCount = result[0]?.count || 0\n\n  await db.execute(\n    \"update conversations set messageCount = $1 where id = $2\",\n    [actualCount, conversationId]\n  )\n}\n"
  },
  {
    "path": "src/db/index.ts",
    "content": "\nimport Database from '@tauri-apps/plugin-sql';\n\n// 导出数据库实例\nexport const db = await Database.load('sqlite:note.db');\n\n// 获取数据库实例(兼容旧代码)\nexport async function getDb() {\n  return db;\n}\n\n// 初始化所有数据库\nexport async function initAllDatabases() {\n  // 引入各数据库初始化函数\n  const { initChatsDb } = await import('./chats');\n  const { initMarksDb } = await import('./marks');\n  const { initNotesDb } = await import('./notes');\n  const { initTagsDb } = await import('./tags');\n  const { initVectorDb } = await import('./vector');\n  const { initConversationsDb } = await import('./conversations');\n  const { initMemoriesDb } = await import('./memories');\n  const { initActivityDb } = await import('./activity');\n\n  // 执行初始化：先确保基础表存在，再做 conversations 对 chats 的迁移/补列。\n  await initChatsDb();\n  await initConversationsDb();\n  await initMarksDb();\n  await initNotesDb();\n  await initTagsDb();\n  await initVectorDb();\n  await initMemoriesDb();\n  await initActivityDb();\n}\n"
  },
  {
    "path": "src/db/marks.ts",
    "content": "import { getDb } from \"./index\"\nimport { BaseDirectory, exists, mkdir } from \"@tauri-apps/plugin-fs\"\nimport { insertActivityEvent } from './activity'\nimport { truncateActivityText } from '@/lib/activity/events'\n\nexport interface Mark {\n  id: number\n  tagId: number\n  type: 'scan' | 'text' | 'image' | 'link' | 'file' | 'recording' | 'todo'\n  content?: string\n  desc?: string\n  url: string\n  deleted: 0 | 1\n  createdAt: number\n}\n\n\n// 创建 marks 表\nexport async function initMarksDb() {\n  const isExist = await exists('screenshot', { baseDir: BaseDirectory.AppData})\n  if (!isExist) {\n    await mkdir('screenshot', { baseDir: BaseDirectory.AppData})\n  }\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists marks (\n      id integer primary key autoincrement,\n      tagId integer not null,\n      type text not null,\n      content text default null,\n      url text default null,\n      desc text default null,\n      deleted integer default 0,\n      createdAt integer\n    )\n  `)\n}\n\nexport async function getMarks(id: number) {\n  const db = await getDb();\n  // 根据 tagId 获取 marks，根据 createdAt 倒序\n  return await db.select<Mark[]>(\"select * from marks where tagId = $1 order by createdAt desc\", [id])\n}\n\nexport async function insertMark(mark: Partial<Mark>) {\n  const db = await getDb();\n  const createdAt = Date.now();\n  const result = await db.execute(\n    \"insert into marks (tagId, type, content, url, desc, createdAt, deleted) values ($1, $2, $3, $4, $5, $6, $7)\",\n    [mark.tagId, mark.type,  mark.content, mark.url, mark.desc, createdAt, 0]\n  )\n\n  const preview = truncateActivityText(mark.desc || mark.content || mark.url || '', 140)\n\n  await insertActivityEvent({\n    source: 'record',\n    title: preview || mark.type || 'record',\n    description: preview || mark.type || '',\n    tagId: mark.tagId ?? null,\n    dedupeKey: result.lastInsertId ? `record:${result.lastInsertId}` : `record:${createdAt}:${mark.type || 'record'}`,\n    createdAt,\n  })\n\n  return result\n}\n\nexport async function getAllMarks() {\n  const db = await getDb();\n  return await db.select<Mark[]>(\"select * from marks order by createdAt desc\")\n}\n\nexport async function updateMark(mark: Mark) {\n  const db = await getDb();\n  const res = await db.execute(\n    \"update marks set tagId = $1, url = $2, desc = $3, content = $4, createdAt = $5 where id = $6\",\n    [mark.tagId, mark.url, mark.desc, mark.content, mark.createdAt, mark.id]\n  )\n  return res \n}\n\nexport async function restoreMark(id: number) {\n  const db = await getDb();\n  const createdAt = Date.now();\n  return await db.execute(\n    \"update marks set deleted = $1, createdAt = $2 where id = $3\",\n    [0, createdAt, id]\n  )\n}\n\nexport async function delMark(id: number) {\n  const db = await getDb();\n  // 判断有没有 deleted 列，没有就添加\n  const res = await db.select<Mark[]>(\"select * from marks where id = $1\", [id])\n  if (res[0].deleted === undefined) {\n    await db.execute(\"alter table marks add column deleted integer default 0\")\n  }\n  const createdAt = Date.now();\n  return await db.execute(\n    \"update marks set deleted = $1, createdAt = $2 where id = $3\",\n    [1, createdAt, id]\n  )\n}\n\nexport async function deleteAllMarks() {\n  const db = await getDb();\n  return await db.execute(\"delete from marks\")\n}\n\nexport async function insertMarks(marks: Partial<Mark>[]) {\n  const db = await getDb();\n  try {\n    for (const mark of marks) {\n      await db.execute(\n        \"insert into marks (tagId, type, content, url, desc, createdAt, deleted) values ($1, $2, $3, $4, $5, $6, $7)\",\n        [mark.tagId, mark.type, mark.content, mark.url, mark.desc, mark.createdAt, mark.deleted]\n      );\n    }\n  } catch (error) {\n    console.error('Error inserting marks:', error);\n    throw error;\n  }\n}\n\nexport async function delMarkForever(id: number) {\n  const db = await getDb();\n  return await db.execute(\"delete from marks where id = $1\", [id])\n}\n\nexport async function clearTrash() {\n  const db = await getDb();\n  return await db.execute(\"delete from marks where deleted = $1\", [1])\n}\n\nexport async function updateMarks(marks: Mark[]) {\n  const db = await getDb();\n  try {\n    for (const mark of marks) {\n      await db.execute(\n        \"update marks set tagId = $1, url = $2, desc = $3, content = $4, createdAt = $5 where id = $6\",\n        [mark.tagId, mark.url, mark.desc, mark.content, mark.createdAt, mark.id]\n      );\n    }\n  } catch (error) {\n    console.error('Error updating marks:', error);\n    throw error;\n  }\n}\n\nexport async function deleteMarks(ids: number[]) {\n  const db = await getDb();\n  const createdAt = Date.now();\n  try {\n    for (const id of ids) {\n      await db.execute(\n        \"update marks set deleted = $1, createdAt = $2 where id = $3\",\n        [1, createdAt, id]\n      );\n    }\n  } catch (error) {\n    console.error('Error deleting marks:', error);\n    throw error;\n  }\n}\n\nexport async function restoreMarks(ids: number[]) {\n  const db = await getDb();\n  const createdAt = Date.now();\n  try {\n    for (const id of ids) {\n      await db.execute(\n        \"update marks set deleted = $1, createdAt = $2 where id = $3\",\n        [0, createdAt, id]\n      );\n    }\n  } catch (error) {\n    console.error('Error restoring marks:', error);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "src/db/memories.ts",
    "content": "import { getDb } from './index'\nimport { fetchEmbedding } from '@/lib/ai/embedding'\n\nexport type MemoryCategory = 'preference' | 'memory'\n\nexport interface Memory {\n  id: string\n  content: string\n  embedding: string // JSON string of vector\n  category: MemoryCategory\n  replacedId?: string\n  accessCount: number\n  lastAccessedAt: number\n  createdAt: number\n  updatedAt: number\n}\n\n// 偏好类记忆的关键词\nconst PREFERENCE_KEYWORDS = [\n  '中文', '英文', '清单体', '段落', '简洁', '详细', 'TL;DR',\n  '格式', '风格', '语言', '回答', '输出', '回复'\n]\n\n/**\n * 自动分类记忆\n */\nfunction categorizeMemory(content: string): MemoryCategory {\n  const lowerContent = content.toLowerCase()\n  const hasPreferenceKeyword = PREFERENCE_KEYWORDS.some(keyword =>\n    lowerContent.includes(keyword.toLowerCase())\n  )\n  return hasPreferenceKeyword ? 'preference' : 'memory'\n}\n\n/**\n * 计算余弦相似度\n */\nfunction cosineSimilarity(vecA: number[], vecB: number[]): number {\n  if (vecA.length !== vecB.length) {\n    return 0\n  }\n\n  let dotProduct = 0\n  let normA = 0\n  let normB = 0\n\n  for (let i = 0; i < vecA.length; i++) {\n    dotProduct += vecA[i] * vecB[i]\n    normA += vecA[i] * vecA[i]\n    normB += vecB[i] * vecB[i]\n  }\n\n  if (normA === 0 || normB === 0) return 0\n\n  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))\n}\n\n/**\n * 生成 UUID\n */\nfunction generateUUID(): string {\n  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {\n    const r = Math.random() * 16 | 0\n    const v = c === 'x' ? r : (r & 0x3 | 0x8)\n    return v.toString(16)\n  })\n}\n\n/**\n * 初始化记忆表\n */\nexport async function initMemoriesDb() {\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists memories (\n      id text primary key,\n      content text not null,\n      embedding text,\n      category text not null check(category IN ('preference', 'memory')),\n      replaced_id text,\n      access_count integer default 0,\n      last_accessed_at integer,\n      created_at integer not null,\n      updated_at integer not null\n    )\n  `)\n\n  // 创建索引\n  await db.execute(`\n    create index if not exists idx_memories_category on memories(category)\n  `)\n\n  await db.execute(`\n    create index if not exists idx_memories_access_count on memories(access_count)\n  `)\n}\n\n/**\n * 插入或更新记忆（带去重功能）\n */\nexport async function upsertMemory(\n  memory: Omit<Memory, 'id' | 'createdAt' | 'updatedAt' | 'accessCount' | 'lastAccessedAt' | 'category'> & { category?: MemoryCategory }\n): Promise<{ id: string; replaced: boolean; replacedId?: string }> {\n  const db = await getDb()\n\n  // 自动分类（如果未指定）\n  const category = memory.category || categorizeMemory(memory.content)\n\n  // 计算向量嵌入\n  let embedding: number[] | null = null\n  if (memory.embedding) {\n    try {\n      embedding = JSON.parse(memory.embedding) as number[]\n    } catch {\n      // 如果解析失败，重新计算\n    }\n  }\n\n  if (!embedding) {\n    embedding = await fetchEmbedding(memory.content)\n  }\n\n  if (!embedding) {\n    throw new Error('无法计算向量嵌入，请检查嵌入模型配置')\n  }\n\n  const embeddingStr = JSON.stringify(embedding)\n\n  // 检查是否存在相似记忆（去重）\n  const allMemories = await getAllMemories()\n  const SIMILARITY_THRESHOLD = 0.85\n\n  let similarMemory: Memory | null = null\n  let maxSimilarity = 0\n\n  for (const existingMemory of allMemories) {\n    // 只在同一类别内查找相似记忆\n    if (existingMemory.category !== category) continue\n\n    if (!existingMemory.embedding) continue\n\n    try {\n      const existingEmbedding = JSON.parse(existingMemory.embedding) as number[]\n      const similarity = cosineSimilarity(embedding, existingEmbedding)\n\n      if (similarity > maxSimilarity) {\n        maxSimilarity = similarity\n        similarMemory = existingMemory\n      }\n    } catch {\n      continue\n    }\n  }\n\n  const now = Date.now()\n  let replaced = false\n  let replacedId: string | undefined\n  let newId: string\n\n  if (similarMemory && maxSimilarity >= SIMILARITY_THRESHOLD) {\n    // 替换旧记忆\n    newId = similarMemory.id\n    replacedId = similarMemory.id\n    replaced = true\n\n    await db.execute(\n      `update memories set content = $1, embedding = $2, category = $3,\n       replaced_id = $4, updated_at = $5 where id = $6`,\n      [memory.content, embeddingStr, category, similarMemory.id, now, newId]\n    )\n  } else {\n    // 插入新记忆\n    newId = generateUUID()\n\n    await db.execute(\n      `insert into memories (id, content, embedding, category, replaced_id,\n       access_count, last_accessed_at, created_at, updated_at)\n       values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,\n      [newId, memory.content, embeddingStr, category, null, 0, now, now, now]\n    )\n  }\n\n  return { id: newId, replaced, replacedId }\n}\n\n/**\n * 获取所有记忆\n */\nexport async function getAllMemories(): Promise<Memory[]> {\n  const db = await getDb()\n  const result = await db.select<Memory[]>(\n    `select id, content, embedding, category, replaced_id as replacedId,\n       access_count as accessCount, last_accessed_at as lastAccessedAt,\n       created_at as createdAt, updated_at as updatedAt\n       from memories order by updated_at desc`\n  )\n  return result\n}\n\n/**\n * 根据类别获取记忆\n */\nexport async function getMemoriesByCategory(category: MemoryCategory): Promise<Memory[]> {\n  const db = await getDb()\n  const result = await db.select<Memory[]>(\n    `select id, content, embedding, category, replaced_id as replacedId,\n       access_count as accessCount, last_accessed_at as lastAccessedAt,\n       created_at as createdAt, updated_at as updatedAt\n       from memories where category = $1 order by updated_at desc`,\n    [category]\n  )\n  return result\n}\n\n/**\n * 获取相似记忆（用于去重）\n */\nexport async function getSimilarMemories(\n  embedding: number[],\n  threshold: number = 0.85\n): Promise<Array<{ memory: Memory; similarity: number }>> {\n  const allMemories = await getAllMemories()\n  const results: Array<{ memory: Memory; similarity: number }> = []\n\n  for (const memory of allMemories) {\n    if (!memory.embedding) continue\n\n    try {\n      const memoryEmbedding = JSON.parse(memory.embedding) as number[]\n      const similarity = cosineSimilarity(embedding, memoryEmbedding)\n\n      if (similarity >= threshold) {\n        results.push({ memory, similarity })\n      }\n    } catch {\n      continue\n    }\n  }\n\n  // 按相似度降序排序\n  results.sort((a, b) => b.similarity - a.similarity)\n\n  return results\n}\n\n/**\n * 根据 ID 获取记忆\n */\nexport async function getMemoryById(id: string): Promise<Memory | null> {\n  const db = await getDb()\n  const result = await db.select<Memory[]>(\n    `select id, content, embedding, category, replaced_id as replacedId,\n       access_count as accessCount, last_accessed_at as lastAccessedAt,\n       created_at as createdAt, updated_at as updatedAt\n       from memories where id = $1`,\n    [id]\n  )\n  return result[0] || null\n}\n\n/**\n * 更新记忆访问统计\n */\nexport async function updateMemoryAccess(id: string): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"update memories set access_count = access_count + 1, last_accessed_at = $1 where id = $2\",\n    [Date.now(), id]\n  )\n}\n\n/**\n * 更新记忆内容\n */\nexport async function updateMemory(\n  id: string,\n  updates: Partial<Pick<Memory, 'content' | 'category' | 'embedding'>>\n): Promise<void> {\n  const db = await getDb()\n\n  // 如果更新内容，需要重新计算嵌入和分类\n  let newEmbedding = updates.embedding\n  let newCategory = updates.category\n\n  if (updates.content && !updates.embedding) {\n    newEmbedding = JSON.stringify(await fetchEmbedding(updates.content) || [])\n  }\n\n  if (updates.content && !updates.category) {\n    newCategory = categorizeMemory(updates.content)\n  }\n\n  await db.execute(\n    `update memories set\n     content = coalesce($1, content),\n     embedding = coalesce($2, embedding),\n     category = coalesce($3, category),\n     updated_at = $4\n     where id = $5`,\n    [updates.content, newEmbedding, newCategory, Date.now(), id]\n  )\n}\n\n/**\n * 删除记忆\n */\nexport async function deleteMemory(id: string): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"delete from memories where id = $1\",\n    [id]\n  )\n}\n\n/**\n * 清空所有记忆\n */\nexport async function clearAllMemories(): Promise<void> {\n  const db = await getDb()\n  await db.execute(\n    \"delete from memories\"\n  )\n}\n\n/**\n * 获取记忆统计信息\n */\nexport async function getMemoryStats(): Promise<{\n  total: number\n  preferences: number\n  memories: number\n  totalAccessCount: number\n}> {\n  const allMemories = await getAllMemories()\n  const preferences = allMemories.filter(m => m.category === 'preference').length\n  const memories = allMemories.filter(m => m.category === 'memory').length\n  const totalAccessCount = allMemories.reduce((sum, m) => sum + m.accessCount, 0)\n\n  return {\n    total: allMemories.length,\n    preferences,\n    memories,\n    totalAccessCount\n  }\n}\n"
  },
  {
    "path": "src/db/notes.ts",
    "content": "import { BaseDirectory, exists, mkdir } from \"@tauri-apps/plugin-fs\"\nimport { getDb } from \"./index\"\n\nexport interface Note {\n  id: number\n  tagId: number\n  content?: string\n  locale: string\n  count: string\n  createdAt: number\n}\n\n// 创建 marks 表\nexport async function initNotesDb() {\n  const isExist = await exists('article', { baseDir: BaseDirectory.AppData})\n  if (!isExist) {\n    await mkdir('article', { baseDir: BaseDirectory.AppData})\n  }\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists notes (\n      id integer primary key autoincrement,\n      tagId integer not null,\n      content text default null,\n      locale text not null,\n      count text not null,\n      createdAt integer not null\n    )\n  `)\n}\n\nexport async function insertNote(note: Partial<Note>) {\n  const db = await getDb()\n  const createdAt = Date.now();\n  return await db.execute(\n    \"insert into notes (tagId, content, locale, count, createdAt) values ($1, $2, $3, $4, $5)\",\n    [note.tagId, note.content, note.locale, note.count, createdAt]\n  )\n}\n\nexport async function getNoteByTagId(tagId: number) {\n  const db = await getDb()\n  return (await db.select<Note[]>(\"select * from notes where tagId = $1 order by createdAt desc limit 1\", [tagId]))[0]\n}\n\nexport async function getNoteById(id: number) {\n  const db = await getDb()\n  // 根据 id 获取 note\n  return (await db.select<Note[]>(\"select * from notes where id = $1\", [id]))[0]\n}\n\nexport async function getNotesByTagId(tagId: number) {\n  const db = await getDb()\n  return await db.select<Note[]>(\"select * from notes where tagId = $1 order by createdAt desc\", [tagId])\n}\n\n// 删除\nexport async function delNote(id: number) {\n  const db = await getDb()\n  return await db.execute(\"delete from notes where id = $1\", [id])\n}\n"
  },
  {
    "path": "src/db/tags.ts",
    "content": "import { getDb } from \"./index\"\nimport { Store } from '@tauri-apps/plugin-store';\n\nexport interface Tag {\n  id: number\n  name: string\n  isLocked?: boolean\n  isPin?: boolean\n  sortOrder?: number\n  total?: number\n}\n\n// 创建 tags 表\nexport async function initTagsDb() {\n  const db = await getDb()\n  await db.execute(`\n    create table if not exists tags (\n      id integer primary key autoincrement,\n      name text not null,\n      isLocked boolean DEFAULT false,\n      isPin boolean DEFAULT false,\n      sortOrder integer DEFAULT 0\n    )\n  `)\n  \n  // 检查 sortOrder 列是否存在，如果不存在则添加\n  try {\n    await db.execute(\"select sortOrder from tags limit 1\")\n  } catch {\n    // sortOrder 列不存在，添加该列\n    await db.execute(\"alter table tags add column sortOrder integer DEFAULT 0\")\n    \n    // 为现有标签设置初始排序值\n    const existingTags = await db.select<Tag[]>(\"select id from tags order by id asc\")\n    for (let i = 0; i < existingTags.length; i++) {\n      await db.execute(\"update tags set sortOrder = $1 where id = $2\", [i, existingTags[i].id])\n    }\n  }\n  \n  const hasDefaultTag = (await db.select<Tag[]>(\"select * from tags\")).length === 0\n  if (hasDefaultTag) {\n    await db.execute(\n      \"insert into tags (name, isLocked, isPin) values ($1, $2, $3)\",\n      ['Idea', true, true]\n    )\n    const tag = (await db.select<Tag[]>(\"select * from tags where name = $1\", ['Idea']))[0]\n    const store = await Store.load('store.json');\n    await store.set('currentTagId', tag.id)\n    await store.save()\n  }\n}\n\nexport async function getTags() {\n  const db = await getDb();\n  const tags = await db.select<Tag[]>(\"select * from tags order by sortOrder asc, id asc\")\n\n  // 获取 tags 对应的 marks 数量\n  for (const tag of tags) {\n    // deleted = 0  \n    const res = await db.select<{ total: number }[]>(\"select count(*) as total from marks where tagId = $1 and deleted = $2\", [tag.id, 0])\n    tag.total = res[0].total\n  }\n\n  return tags\n}\n\nexport async function insertTag(tag: Partial<Tag>) {\n  const db = await getDb();\n  return await db.execute(\n    \"insert into tags (name) values ($1)\",\n    [tag.name]\n  )\n}\n\nexport async function updateTag(tag: Tag) {\n  const db = await getDb();\n  return await db.execute(\n    \"update tags set name = $1, isLocked = $2, isPin = $3, sortOrder = $4 where id = $5\",\n    [tag.name, tag.isLocked, tag.isPin, tag.sortOrder, tag.id]\n  )\n}\n\nexport async function delTag(id: number) {\n  const db = await getDb();\n  return await db.execute(\"delete from tags where id = $1\", [id])\n}\n\nexport async function deleteAllTags() {\n  const db = await getDb();\n  return await db.execute(\"delete from tags where isLocked = false\")\n}\n\nexport async function insertTags(tags: Tag[]) {\n  const db = await getDb();\n  for (const tag of tags) {\n    if (tag.isLocked) continue;\n    const exists = await db.select<Tag[]>(\"select * from tags where id = $1\", [tag.id])\n    if (exists.length > 0) {\n      await db.execute(\n        \"update tags set name = $1, isLocked = $2, isPin = $3, sortOrder = $4 where id = $5\",\n        [tag.name, tag.isLocked, tag.isPin, tag.sortOrder, tag.id]\n      )\n    } else {\n      await db.execute(\n        \"insert into tags (id, name, isLocked, isPin, sortOrder) values ($1, $2, $3, $4, $5)\",\n        [tag.id, tag.name, tag.isLocked, tag.isPin, tag.sortOrder]\n      )\n    }\n  }\n  return true;\n}\n\nexport async function updateTagsOrder(tags: { id: number; sortOrder: number }[]) {\n  const db = await getDb();\n  for (const tag of tags) {\n    await db.execute(\n      \"update tags set sortOrder = $1 where id = $2\",\n      [tag.sortOrder, tag.id]\n    )\n  }\n  return true;\n}"
  },
  {
    "path": "src/db/vector.ts",
    "content": "import { db } from './index';\n\n// 向量数据库表结构定义\nexport interface VectorDocument {\n  id: number;\n  filename: string;   // 文件名\n  chunk_id: number;   // 分块ID\n  content: string;    // 分块内容\n  embedding: string;  // 存储为JSON字符串的向量\n  updated_at: number; // 时间戳\n}\n\n// 向量缓存项\ninterface CachedVector {\n  id: number;\n  filename: string;\n  content: string;\n  embedding: number[];  // 解析后的向量\n  updated_at: number;\n}\n\n// 向量缓存管理\nclass VectorCache {\n  private cache: Map<number, CachedVector> = new Map();\n  private vectorsByFilename: Map<string, number[]> = new Map(); // 文件名到向量ID列表的映射\n  private lastUpdate: number = 0;\n  private cacheVersion: number = 0;\n\n  // 获取缓存版本号，用于判断缓存是否过期\n  getVersion(): number {\n    return this.cacheVersion;\n  }\n\n  // 从缓存获取所有向量\n  getAll(): CachedVector[] {\n    return Array.from(this.cache.values());\n  }\n\n  // 按文件名获取向量\n  getByFilename(filename: string): CachedVector[] {\n    const ids = this.vectorsByFilename.get(filename) || [];\n    return ids.map(id => this.cache.get(id)).filter(Boolean) as CachedVector[];\n  }\n\n  // 更新缓存\n  async update() {\n    const docs = await db.select<VectorDocument[]>(`\n      select id, filename, content, embedding, updated_at from vector_documents\n    `);\n\n    // 清空旧缓存\n    this.cache.clear();\n    this.vectorsByFilename.clear();\n\n    // 构建新缓存\n    for (const doc of docs) {\n      try {\n        const embedding = JSON.parse(doc.embedding) as number[];\n        const cached: CachedVector = {\n          id: doc.id,\n          filename: doc.filename,\n          content: doc.content,\n          embedding,\n          updated_at: doc.updated_at\n        };\n        this.cache.set(doc.id, cached);\n\n        // 按文件名索引\n        if (!this.vectorsByFilename.has(doc.filename)) {\n          this.vectorsByFilename.set(doc.filename, []);\n        }\n        this.vectorsByFilename.get(doc.filename)!.push(doc.id);\n      } catch (error) {\n        console.error(`Failed to parse embedding for doc ${doc.id}:`, error);\n      }\n    }\n\n    this.lastUpdate = Date.now();\n    this.cacheVersion++;\n  }\n\n  // 添加单个向量到缓存\n  add(doc: VectorDocument) {\n    try {\n      const embedding = JSON.parse(doc.embedding) as number[];\n      const cached: CachedVector = {\n        id: doc.id,\n        filename: doc.filename,\n        content: doc.content,\n        embedding,\n        updated_at: doc.updated_at\n      };\n      this.cache.set(doc.id, cached);\n\n      if (!this.vectorsByFilename.has(doc.filename)) {\n        this.vectorsByFilename.set(doc.filename, []);\n      }\n      this.vectorsByFilename.get(doc.filename)!.push(doc.id);\n      this.cacheVersion++;\n    } catch (error) {\n      console.error(`Failed to add vector to cache for doc ${doc.id}:`, error);\n    }\n  }\n\n  // 删除文件的所有向量\n  deleteByFilename(filename: string) {\n    const ids = this.vectorsByFilename.get(filename) || [];\n    for (const id of ids) {\n      this.cache.delete(id);\n    }\n    this.vectorsByFilename.delete(filename);\n    this.cacheVersion++;\n  }\n\n  // 检查是否需要更新缓存（5分钟过期）\n  needsUpdate(): boolean {\n    return Date.now() - this.lastUpdate > 5 * 60 * 1000 || this.cache.size === 0;\n  }\n}\n\n// 全局向量缓存实例\nconst vectorCache = new VectorCache();\n\n// 初始化向量数据库表\nexport async function initVectorDb() {\n  await db.execute(`\n    create table if not exists vector_documents (\n      id integer primary key autoincrement,\n      filename text not null,\n      chunk_id integer not null,\n      content text not null,\n      embedding text not null,\n      updated_at integer not null,\n      unique(filename, chunk_id)\n    )\n  `);\n\n  // 创建用于快速查找文件的索引\n  await db.execute(`\n    create index if not exists idx_vector_documents_filename\n    on vector_documents(filename)\n  `);\n\n  // 初始化缓存\n  await vectorCache.update();\n}\n\n// 插入或更新向量文档\nexport async function upsertVectorDocument(doc: Omit<VectorDocument, 'id'>) {\n  await db.execute(\n    \"insert into vector_documents (filename, chunk_id, content, embedding, updated_at) values ($1, $2, $3, $4, $5) on conflict(filename, chunk_id) do update set content = excluded.content, embedding = excluded.embedding, updated_at = excluded.updated_at\",\n    [doc.filename, doc.chunk_id, doc.content, doc.embedding, doc.updated_at]);\n\n  // 获取插入的文档ID并更新缓存\n  const inserted = await db.select<VectorDocument[]>(\n    \"select * from vector_documents where filename = $1 and chunk_id = $2\",\n    [doc.filename, doc.chunk_id]\n  );\n\n  if (inserted.length > 0) {\n    vectorCache.add(inserted[0]);\n  }\n}\n\n// 获取指定文件名的所有向量文档\nexport async function getVectorDocumentsByFilename(filename: string) {\n  return await db.select<VectorDocument[]>(\n    \"select * from vector_documents where filename = $1 order by chunk_id\",\n    [filename]);\n}\n\n// 通过文件名删除向量文档\nexport async function deleteVectorDocumentsByFilename(filename: string) {\n  await db.execute(\n    \"delete from vector_documents where filename = $1\",\n    [filename]);\n\n  // 从缓存中删除\n  vectorCache.deleteByFilename(filename);\n}\n\n// 检查文件是否已存在于向量数据库中\nexport async function checkVectorDocumentExists(filename: string) {\n  const result = await db.select<{ count: number }[]>(\n    \"select count(*) as count from vector_documents where filename = $1\",\n    [filename]);\n  \n  return result[0]?.count > 0;\n}\n\n// 获取最相似的文档片段（优化版本：使用缓存）\nexport async function getSimilarDocuments(\n  queryEmbedding: number[],\n  limit: number = 5,\n  threshold: number = 0.7\n): Promise<{id: number, filename: string, content: string, similarity: number}[]> {\n  // 检查是否需要更新缓存\n  if (vectorCache.needsUpdate()) {\n    await vectorCache.update();\n  }\n\n  // 从缓存获取所有向量（已解析，避免重复 JSON.parse）\n  const cachedVectors = vectorCache.getAll();\n\n  if (!cachedVectors.length) return [];\n\n  // 计算余弦相似度并排序\n  const allSimilarities = cachedVectors.map(doc => {\n    const similarity = cosineSimilarity(queryEmbedding, doc.embedding);\n\n    return {\n      id: doc.id,\n      filename: doc.filename,\n      content: doc.content,\n      similarity\n    };\n  });\n\n  const results = allSimilarities\n  .filter(doc => doc.similarity >= threshold)\n  .sort((a, b) => b.similarity - a.similarity)\n  .slice(0, limit);\n\n  return results;\n}\n\n// 余弦相似度计算\nfunction cosineSimilarity(vecA: number[], vecB: number[]): number {\n  if (vecA.length !== vecB.length) {\n    throw new Error('向量维度不匹配');\n  }\n  \n  let dotProduct = 0;\n  let normA = 0;\n  let normB = 0;\n  \n  for (let i = 0; i < vecA.length; i++) {\n    dotProduct += vecA[i] * vecB[i];\n    normA += vecA[i] * vecA[i];\n    normB += vecB[i] * vecB[i];\n  }\n  \n  if (normA === 0 || normB === 0) return 0;\n  \n  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));\n}\n\n// 清空向量数据库\nexport async function clearVectorDb() {\n  await db.execute(`\n    delete from vector_documents\n  `);\n\n  // 清空缓存\n  await vectorCache.update();\n}\n\n// 获取所有向量文档的文件名列表\nexport async function getAllVectorDocumentFilenames() {\n  return await db.select<{filename: string}[]>(`\n    select distinct filename from vector_documents\n  `);\n}\n\n// 手动刷新向量缓存\nexport async function refreshVectorCache() {\n  await vectorCache.update();\n}\n"
  },
  {
    "path": "src/hooks/use-file-shortcuts.ts",
    "content": "import { useEffect, useCallback, useState } from 'react'\nimport { isMobileDevice } from '@/lib/check'\nimport { platform } from '@tauri-apps/plugin-os'\nimport useArticleStore from '@/stores/article'\n\ntype Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\ninterface FileShortcutsProps {\n  path: string\n  isEditing?: boolean\n  onStartRename?: () => void\n  onCopy?: () => void\n  onPaste?: () => void\n  onCut?: () => void\n  onDelete?: () => void\n}\n\n/**\n * 文件和文件夹快捷键 Hook\n * 桌面端：\n *   - macOS: Enter 键触发重命名，Cmd+C 复制，Cmd+V 粘贴，Cmd+X 剪切，Backspace 删除\n *   - Windows/Linux: F2 键触发重命名，Ctrl+C 复制，Ctrl+V 粘贴，Ctrl+X 剪切，Delete 删除\n * 移动端：不启用快捷键\n */\nexport function useFileShortcuts({\n  path,\n  isEditing,\n  onStartRename,\n  onCopy,\n  onPaste,\n  onCut,\n  onDelete\n}: FileShortcutsProps) {\n  const { activeFilePath } = useArticleStore()\n  const [currentPlatform, setCurrentPlatform] = useState<Platform>('unknown')\n\n  // 检测当前平台\n  useEffect(() => {\n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch {\n      setCurrentPlatform('unknown')\n    }\n  }, [])\n\n  // 检查是否按下了正确的修饰键\n  const isModKey = useCallback((e: KeyboardEvent | React.KeyboardEvent): boolean => {\n    if (currentPlatform === 'macos') {\n      return e.metaKey && !e.ctrlKey\n    } else {\n      return e.ctrlKey && !e.metaKey\n    }\n  }, [currentPlatform])\n\n  const handleKeyDown = useCallback((e: KeyboardEvent) => {\n    // 移动端不处理快捷键\n    if (isMobileDevice()) {\n      return\n    }\n\n    // 正在编辑时也忽略\n    if (isEditing === true) {\n      return\n    }\n\n    // 只处理选中的文件/文件夹\n    if (path !== activeFilePath) {\n      return\n    }\n\n    const modPressed = isModKey(e)\n\n    // 重命名: macOS 使用 Enter 键，Windows/Linux 使用 F2 键\n    const isRenameKey = currentPlatform === 'macos'\n      ? e.key === 'Enter'\n      : e.key === 'F2'\n\n    if (isRenameKey && onStartRename) {\n      e.preventDefault()\n      e.stopPropagation()\n      onStartRename()\n      return\n    }\n\n    // 复制: Cmd+C / Ctrl+C\n    if (modPressed && e.key === 'c' && onCopy) {\n      e.preventDefault()\n      e.stopPropagation()\n      onCopy()\n      return\n    }\n\n    // 粘贴: Cmd+V / Ctrl+V\n    if (modPressed && e.key === 'v' && onPaste) {\n      e.preventDefault()\n      e.stopPropagation()\n      onPaste()\n      return\n    }\n\n    // 剪切: Cmd+X / Ctrl+X\n    if (modPressed && e.key === 'x' && onCut) {\n      e.preventDefault()\n      e.stopPropagation()\n      onCut()\n      return\n    }\n\n    // 删除: macOS 使用 Backspace，Windows/Linux 使用 Delete\n    const isDeleteKey = currentPlatform === 'macos'\n      ? e.key === 'Backspace'\n      : e.key === 'Delete'\n\n    if (isDeleteKey && onDelete) {\n      e.preventDefault()\n      e.stopPropagation()\n      onDelete()\n      return\n    }\n  }, [activeFilePath, isEditing, onStartRename, onCopy, onPaste, onCut, onDelete, path, currentPlatform, isModKey])\n\n  useEffect(() => {\n    // 移动端不添加事件监听\n    if (isMobileDevice() || currentPlatform === 'unknown') {\n      return\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown)\n    }\n  }, [handleKeyDown, currentPlatform])\n\n  return { currentPlatform, isModKey }\n}\n"
  },
  {
    "path": "src/hooks/use-mobile.tsx",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "src/hooks/use-sync-manager.ts",
    "content": "'use client'\n\nimport * as React from 'react'\nimport { getSyncManager, SyncResult } from '@/lib/sync/sync-manager'\nimport { readTextFile } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\n\ninterface UseSyncManagerOptions {\n  autoRefresh?: boolean\n  refreshInterval?: number\n}\n\nexport function useSyncManager(path?: string, options: UseSyncManagerOptions = {}) {\n  const { autoRefresh = false, refreshInterval = 30000 } = options\n  const [status, setStatus] = React.useState<'synced' | 'local_newer' | 'remote_newer' | 'conflict' | 'unknown' | 'syncing' | 'offline'>('unknown')\n  const [lastSyncTime, setLastSyncTime] = React.useState<number>(0)\n  const [isPending, setIsPending] = React.useState(false)\n  const [isLoading, setIsLoading] = React.useState(false)\n  const [error, setError] = React.useState<string | null>(null)\n\n  const manager = React.useMemo(() => getSyncManager(), [])\n\n  const checkStatus = async (filePath: string) => {\n    setIsLoading(true)\n    setError(null)\n\n    try {\n      const syncStatus = await manager.getFileSyncStatus(filePath)\n      setStatus(syncStatus)\n\n      const state = manager.getState()\n      setLastSyncTime(state.lastSyncTime)\n      setIsPending(state.pendingSync)\n    } catch (err) {\n      console.error('Failed to check sync status:', err)\n      setStatus('unknown')\n      setError(err instanceof Error ? err.message : 'Failed to check status')\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const sync = async (): Promise<SyncResult | null> => {\n    if (!path) return null\n\n    setStatus('syncing')\n    setIsLoading(true)\n\n    try {\n      const result = await manager.syncFile(path)\n      await checkStatus(path)\n      return result\n    } catch (err) {\n      console.error('Sync failed:', err)\n      setStatus('conflict')\n      setError(err instanceof Error ? err.message : 'Sync failed')\n      return null\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const push = async (): Promise<SyncResult | null> => {\n    if (!path) return null\n\n    setIsLoading(true)\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(path)\n      const content = workspace.isCustom\n        ? await readTextFile(pathOptions.path)\n        : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n      const result = await manager.pushFile(path, content)\n      await checkStatus(path)\n      return result\n    } catch (err) {\n      console.error('Push failed:', err)\n      return null\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  const pull = async (): Promise<SyncResult | null> => {\n    if (!path) return null\n\n    setIsLoading(true)\n    try {\n      const result = await manager.pullFile(path)\n      await checkStatus(path)\n      return result\n    } catch (err) {\n      console.error('Pull failed:', err)\n      return null\n    } finally {\n      setIsLoading(false)\n    }\n  }\n\n  // 自动刷新状态\n  React.useEffect(() => {\n    if (!autoRefresh || !path) return\n\n    checkStatus(path)\n\n    const interval = setInterval(() => {\n      checkStatus(path)\n    }, refreshInterval)\n\n    return () => clearInterval(interval)\n  }, [path, autoRefresh, refreshInterval])\n\n  // 初始检查\n  React.useEffect(() => {\n    if (path) {\n      checkStatus(path)\n    }\n  }, [path])\n\n  return {\n    status,\n    lastSyncTime,\n    isPending,\n    isLoading,\n    error,\n    checkStatus,\n    sync,\n    push,\n    pull,\n    getConfig: () => manager.getConfig(),\n    updateConfig: (config: any) => manager.updateConfig(config),\n  }\n}\n"
  },
  {
    "path": "src/hooks/use-sync-settings.ts",
    "content": "'use client'\n\nimport { Store } from '@tauri-apps/plugin-store'\nimport { useState, useEffect, useCallback, useRef } from 'react'\nimport { SyncPlatform } from '@/types/sync'\n\n// 缓存 Store 实例\nlet storeInstance: Store | null = null\nlet storePromise: Promise<Store> | null = null\n\nasync function getStore(): Promise<Store> {\n  if (storeInstance) return storeInstance\n  if (storePromise) return storePromise\n\n  storePromise = Store.load('store.json')\n  storeInstance = await storePromise\n  return storeInstance\n}\n\nexport interface SyncPlatformStatus {\n  hasToken: boolean\n  isConnected: boolean\n  repoName: string | null\n  lastSync: number | null\n}\n\nexport interface UseSyncSettingsReturn {\n  // 加载状态\n  isLoading: boolean\n  error: string | null\n  clearError: () => void\n\n  // Token 操作\n  getToken: (platform: SyncPlatform) => Promise<string | null>\n  setToken: (platform: SyncPlatform, token: string) => Promise<void>\n\n  // 各平台状态\n  platformStatus: Record<SyncPlatform, SyncPlatformStatus>\n  updatePlatformStatus: (platform: SyncPlatform, status: Partial<SyncPlatformStatus>) => void\n\n  // 刷新所有平台状态\n  refreshAllStatus: () => Promise<void>\n}\n\nconst defaultPlatformStatus: SyncPlatformStatus = {\n  hasToken: false,\n  isConnected: false,\n  repoName: null,\n  lastSync: null,\n}\n\nexport function useSyncSettings(): UseSyncSettingsReturn {\n  const [isLoading, setIsLoading] = useState(true)\n  const [error, setError] = useState<string | null>(null)\n  const [platformStatus, setPlatformStatus] = useState<Record<SyncPlatform, SyncPlatformStatus>>({\n    github: { ...defaultPlatformStatus },\n    gitee: { ...defaultPlatformStatus },\n    gitlab: { ...defaultPlatformStatus },\n    gitea: { ...defaultPlatformStatus },\n    s3: { ...defaultPlatformStatus },\n    webdav: { ...defaultPlatformStatus },\n  })\n\n  const initialized = useRef(false)\n\n  // 初始化加载所有平台状态\n  useEffect(() => {\n    if (initialized.current) return\n    initialized.current = true\n\n    const init = async () => {\n      try {\n        const store = await getStore()\n\n        const platforms: SyncPlatform[] = ['github', 'gitee', 'gitlab', 'gitea', 's3', 'webdav']\n        const statusMap: Record<SyncPlatform, SyncPlatformStatus> = { ...platformStatus }\n\n        await Promise.all(\n          platforms.map(async (platform) => {\n            const tokenKey = `${platform}AccessToken` as const\n            const token = await store.get<string>(tokenKey)\n\n            statusMap[platform] = {\n              hasToken: !!token,\n              isConnected: false, // 需要各组件单独检查\n              repoName: null,\n              lastSync: null,\n            }\n          })\n        )\n\n        setPlatformStatus(statusMap)\n      } catch (err) {\n        console.error('Failed to init sync settings:', err)\n        setError(err instanceof Error ? err.message : 'Failed to load settings')\n      } finally {\n        setIsLoading(false)\n      }\n    }\n\n    init()\n  }, [])\n\n  const clearError = useCallback(() => {\n    setError(null)\n  }, [])\n\n  const getToken = useCallback(async (platform: SyncPlatform): Promise<string | null> => {\n    try {\n      const store = await getStore()\n      const tokenKey = `${platform}AccessToken` as const\n      const token = await store.get<string>(tokenKey)\n      return token || null\n    } catch (err) {\n      console.error('Failed to get token:', err)\n      return null\n    }\n  }, [])\n\n  const setToken = useCallback(async (platform: SyncPlatform, token: string) => {\n    try {\n      const store = await getStore()\n      const tokenKey = `${platform}AccessToken` as const\n      await store.set(tokenKey, token)\n      await store.save()\n\n      setPlatformStatus((prev) => ({\n        ...prev,\n        [platform]: {\n          ...prev[platform],\n          hasToken: !!token,\n        },\n      }))\n    } catch (err) {\n      console.error('Failed to set token:', err)\n      setError(err instanceof Error ? err.message : 'Failed to save token')\n    }\n  }, [])\n\n  const updatePlatformStatus = useCallback(\n    (platform: SyncPlatform, status: Partial<SyncPlatformStatus>) => {\n      setPlatformStatus((prev) => ({\n        ...prev,\n        [platform]: {\n          ...prev[platform],\n          ...status,\n        },\n      }))\n    },\n    []\n  )\n\n  const refreshAllStatus = useCallback(async () => {\n    setIsLoading(true)\n    try {\n      const store = await getStore()\n      const platforms: SyncPlatform[] = ['github', 'gitee', 'gitlab', 'gitea']\n\n      await Promise.all(\n        platforms.map(async (platform) => {\n          const tokenKey = `${platform}AccessToken` as const\n          const token = await store.get<string>(tokenKey)\n\n          setPlatformStatus((prev) => ({\n            ...prev,\n            [platform]: {\n              ...prev[platform],\n              hasToken: !!token,\n            },\n          }))\n        })\n      )\n    } catch (err) {\n      console.error('Failed to refresh status:', err)\n      setError(err instanceof Error ? err.message : 'Failed to refresh')\n    } finally {\n      setIsLoading(false)\n    }\n  }, [])\n\n  return {\n    isLoading,\n    error,\n    clearError,\n    getToken,\n    setToken,\n    platformStatus,\n    updatePlatformStatus,\n    refreshAllStatus,\n  }\n}\n"
  },
  {
    "path": "src/hooks/use-toast.ts",
    "content": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type {\n  ToastActionElement,\n  ToastProps,\n} from \"@/components/ui/toast\"\n\nconst TOAST_LIMIT = 1\nconst TOAST_REMOVE_DELAY = 1000000\n\ntype ToasterToast = ToastProps & {\n  id: string\n  title?: React.ReactNode\n  description?: React.ReactNode\n  action?: ToastActionElement\n}\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst actionTypes = {\n  ADD_TOAST: \"ADD_TOAST\",\n  UPDATE_TOAST: \"UPDATE_TOAST\",\n  DISMISS_TOAST: \"DISMISS_TOAST\",\n  REMOVE_TOAST: \"REMOVE_TOAST\",\n} as const\n\nlet count = 0\n\nfunction genId() {\n  count = (count + 1) % Number.MAX_SAFE_INTEGER\n  return count.toString()\n}\n\ntype ActionType = typeof actionTypes\n\ntype Action =\n  | {\n      type: ActionType[\"ADD_TOAST\"]\n      toast: ToasterToast\n    }\n  | {\n      type: ActionType[\"UPDATE_TOAST\"]\n      toast: Partial<ToasterToast>\n    }\n  | {\n      type: ActionType[\"DISMISS_TOAST\"]\n      toastId?: ToasterToast[\"id\"]\n    }\n  | {\n      type: ActionType[\"REMOVE_TOAST\"]\n      toastId?: ToasterToast[\"id\"]\n    }\n\ninterface State {\n  toasts: ToasterToast[]\n}\n\nconst toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()\n\nconst addToRemoveQueue = (toastId: string) => {\n  if (toastTimeouts.has(toastId)) {\n    return\n  }\n\n  const timeout = setTimeout(() => {\n    toastTimeouts.delete(toastId)\n    dispatch({\n      type: \"REMOVE_TOAST\",\n      toastId: toastId,\n    })\n  }, TOAST_REMOVE_DELAY)\n\n  toastTimeouts.set(toastId, timeout)\n}\n\nexport const reducer = (state: State, action: Action): State => {\n  switch (action.type) {\n    case \"ADD_TOAST\":\n      return {\n        ...state,\n        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n      }\n\n    case \"UPDATE_TOAST\":\n      return {\n        ...state,\n        toasts: state.toasts.map((t) =>\n          t.id === action.toast.id ? { ...t, ...action.toast } : t\n        ),\n      }\n\n    case \"DISMISS_TOAST\": {\n      const { toastId } = action\n\n      // ! Side effects ! - This could be extracted into a dismissToast() action,\n      // but I'll keep it here for simplicity\n      if (toastId) {\n        addToRemoveQueue(toastId)\n      } else {\n        state.toasts.forEach((toast) => {\n          addToRemoveQueue(toast.id)\n        })\n      }\n\n      return {\n        ...state,\n        toasts: state.toasts.map((t) =>\n          t.id === toastId || toastId === undefined\n            ? {\n                ...t,\n                open: false,\n              }\n            : t\n        ),\n      }\n    }\n    case \"REMOVE_TOAST\":\n      if (action.toastId === undefined) {\n        return {\n          ...state,\n          toasts: [],\n        }\n      }\n      return {\n        ...state,\n        toasts: state.toasts.filter((t) => t.id !== action.toastId),\n      }\n  }\n}\n\nconst listeners: Array<(state: State) => void> = []\n\nlet memoryState: State = { toasts: [] }\n\nfunction dispatch(action: Action) {\n  memoryState = reducer(memoryState, action)\n  listeners.forEach((listener) => {\n    listener(memoryState)\n  })\n}\n\ntype Toast = Omit<ToasterToast, \"id\">\n\nfunction toast({ ...props }: Toast) {\n  const id = genId()\n\n  const update = (props: ToasterToast) =>\n    dispatch({\n      type: \"UPDATE_TOAST\",\n      toast: { ...props, id },\n    })\n  const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id })\n\n  dispatch({\n    type: \"ADD_TOAST\",\n    toast: {\n      ...props,\n      id,\n      open: true,\n      onOpenChange: (open) => {\n        if (!open) dismiss()\n      },\n    },\n  })\n\n  return {\n    id: id,\n    dismiss,\n    update,\n  }\n}\n\nfunction useToast() {\n  const [state, setState] = React.useState<State>(memoryState)\n\n  React.useEffect(() => {\n    listeners.push(setState)\n    return () => {\n      const index = listeners.indexOf(setState)\n      if (index > -1) {\n        listeners.splice(index, 1)\n      }\n    }\n  }, [state])\n\n  return {\n    ...state,\n    toast,\n    dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n  }\n}\n\nexport { useToast, toast }\n"
  },
  {
    "path": "src/hooks/use-toolbar-shortcuts.ts",
    "content": "import { useEffect, useState, useRef } from 'react'\nimport { platform } from '@tauri-apps/plugin-os'\nimport emitter from '@/lib/emitter'\nimport useSettingStore from '@/stores/setting'\nimport { resolveToolbarShortcutIndex } from '@/lib/toolbar-shortcuts'\n\ntype Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\nexport function useToolbarShortcuts() {\n  const [currentPlatform, setCurrentPlatform] = useState<Platform>('unknown')\n  const [isModifierPressed, setIsModifierPressed] = useState(false)\n  const { recordToolbarConfig } = useSettingStore()\n  const enabledItemsRef = useRef<typeof recordToolbarConfig>([])\n\n  useEffect(() => {\n    try {\n      const p = platform()\n      if (p === 'macos') {\n        setCurrentPlatform('macos')\n      } else if (p === 'windows') {\n        setCurrentPlatform('windows')\n      } else if (p === 'linux') {\n        setCurrentPlatform('linux')\n      }\n    } catch (error) {\n      console.error('Error detecting platform:', error)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (currentPlatform === 'unknown') return\n\n    enabledItemsRef.current = recordToolbarConfig\n      .filter(item => item.enabled)\n      .sort((a, b) => a.order - b.order)\n      .slice(0, 9)\n  }, [currentPlatform, recordToolbarConfig])\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      const shortcutIndex = resolveToolbarShortcutIndex(\n        e,\n        currentPlatform,\n        enabledItemsRef.current.length,\n      )\n\n      if (shortcutIndex !== null) {\n        e.preventDefault()\n        const item = enabledItemsRef.current[shortcutIndex]\n        if (item) {\n          emitter.emit(`toolbar-shortcut-${item.id}` as any)\n        }\n        return\n      }\n\n      if (currentPlatform === 'macos' && e.metaKey) {\n        setIsModifierPressed(true)\n      } else if ((currentPlatform === 'windows' || currentPlatform === 'linux') && e.altKey) {\n        setIsModifierPressed(true)\n      }\n    }\n\n    const handleKeyUp = (e: KeyboardEvent) => {\n      if (currentPlatform === 'macos' && !e.metaKey) {\n        setIsModifierPressed(false)\n      } else if ((currentPlatform === 'windows' || currentPlatform === 'linux') && !e.altKey) {\n        setIsModifierPressed(false)\n      }\n    }\n\n    const handleBlur = () => {\n      setIsModifierPressed(false)\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    window.addEventListener('keyup', handleKeyUp)\n    window.addEventListener('blur', handleBlur)\n\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown)\n      window.removeEventListener('keyup', handleKeyUp)\n      window.removeEventListener('blur', handleBlur)\n    }\n  }, [currentPlatform])\n\n  return {\n    isModifierPressed,\n    currentPlatform,\n  }\n}\n"
  },
  {
    "path": "src/hooks/use-username.ts",
    "content": "import { useMemo } from \"react\"\nimport useSyncStore from \"@/stores/sync\"\nimport useSettingStore from \"@/stores/setting\"\nimport { Store } from \"@tauri-apps/plugin-store\"\n\n// 获取当前主要备份方式的用户名，以确保配置正确\nfunction useUsername() {\n  const { primaryBackupMethod } = useSettingStore()\n  const { userInfo, giteeUserInfo, gitlabUserInfo, giteaUserInfo } = useSyncStore()\n  const username = useMemo(() => {\n    switch (primaryBackupMethod) {\n      case 'github':\n        return userInfo?.login\n      case 'gitee':\n        return giteeUserInfo?.login\n      case 'gitlab':\n        return gitlabUserInfo?.name\n      case 'gitea':\n        return giteaUserInfo?.login\n      case 's3':\n        // S3 使用 bucket 名称作为标识\n        return null // 异步获取，在组件中处理\n    }\n  }, [userInfo, giteeUserInfo, gitlabUserInfo, giteaUserInfo, primaryBackupMethod])\n\n  return username\n}\n\n// 单独导出一个异步函数用于 S3\nexport async function getS3BucketName(): Promise<string | null> {\n  const store = await Store.load('store.json')\n  const s3Config = await store.get<{ bucket: string }>('s3SyncConfig')\n  return s3Config?.bucket || null\n}\n\nexport default useUsername\n"
  },
  {
    "path": "src/hooks/useAiCompletion.ts",
    "content": "import { useState, useCallback, useRef } from 'react'\nimport { fetchCompletion } from '@/lib/ai/completion'\n\ninterface UseAiCompletionOptions {\n  onAccept?: (completion: string) => void\n  onCancel?: () => void\n}\n\nexport function useAiCompletion(options: UseAiCompletionOptions = {}) {\n  const [completion, setCompletion] = useState<string>('')\n  const [isLoading, setIsLoading] = useState(false)\n  const abortControllerRef = useRef<AbortController | null>(null)\n  const completionRef = useRef<string>('') // 用 ref 存储最新的 completion 值\n\n  // 生成补全内容\n  const generateCompletion = useCallback(async (fullContent: string, cursorPosition: number) => {\n    // 取消之前的请求\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n    }\n\n    // 提取光标附近的上下文（前 300 字符）\n    const contextStart = Math.max(0, cursorPosition - 300)\n    const context = fullContent.substring(contextStart, cursorPosition)\n    \n    // 如果上下文太短，不生成补全\n    if (context.trim().length < 10) {\n      return\n    }\n\n    setIsLoading(true)\n    abortControllerRef.current = new AbortController()\n\n    try {\n      const result = await fetchCompletion(context, abortControllerRef.current.signal)\n      \n      if (result) {\n        completionRef.current = result\n        setCompletion(result)\n      }\n    } catch (error: any) {\n      if (error.name !== 'AbortError') {\n        console.error('[useAiCompletion] Error:', error)\n      }\n    } finally {\n      setIsLoading(false)\n      abortControllerRef.current = null\n    }\n  }, [])\n\n  // 接受补全\n  const acceptCompletion = useCallback(() => {\n    const currentCompletion = completionRef.current\n    if (currentCompletion) {\n      // 先清除预览元素\n      const previews = document.querySelectorAll('.ai-completion-preview')\n      previews.forEach(preview => preview.remove())\n      \n      // 调用回调\n      options.onAccept?.(currentCompletion)\n      \n      // 清除状态\n      completionRef.current = ''\n      setCompletion('')\n    }\n  }, [options])\n\n  // 取消补全\n  const cancelCompletion = useCallback(() => {\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n      abortControllerRef.current = null\n    }\n    \n    // 清除预览元素\n    const previews = document.querySelectorAll('.ai-completion-preview')\n    previews.forEach(preview => preview.remove())\n    \n    completionRef.current = ''\n    setCompletion('')\n    setIsLoading(false)\n    options.onCancel?.()\n  }, [options])\n\n  return {\n    completion,\n    isLoading,\n    generateCompletion,\n    acceptCompletion,\n    cancelCompletion,\n  }\n}\n"
  },
  {
    "path": "src/hooks/useI18n.ts",
    "content": "import { useEffect, useState } from 'react';\n\nconst LANGUAGE_KEY = 'app-language';\n\nexport function useI18n() {\n  const [currentLocale, setCurrentLocale] = useState<string>('zh');\n\n  useEffect(() => {\n    const savedLanguage = localStorage.getItem(LANGUAGE_KEY) || 'zh';\n    setCurrentLocale(savedLanguage);\n  }, []);\n\n  const changeLanguage = (locale: string) => {\n    localStorage.setItem(LANGUAGE_KEY, locale);\n    setCurrentLocale(locale);\n    // 刷新页面以应用新语言\n    window.location.reload();\n  };\n\n  return {\n    currentLocale,\n    changeLanguage,\n  };\n}\n"
  },
  {
    "path": "src/i18n/request.ts",
    "content": "import {getRequestConfig} from 'next-intl/server';\nimport {notFound} from 'next/navigation';\n \n// 支持的语言列表\nexport const locales = ['en', 'zh', 'ja', 'pt-BR', 'zh-TW'];\nexport const defaultLocale = 'zh';\n \nexport default getRequestConfig(async ({locale}) => {\n  // 验证语言是否支持\n  if (!locales.includes(locale as any)) notFound();\n \n  return {\n    messages: (await import(`../messages/${locale}.json`)).default\n  };\n});\n"
  },
  {
    "path": "src/lib/activity/aggregate.ts",
    "content": "import type { ActivityDaySummary, ActivityEntry, ActivityHeatmapWeek, ActivitySource } from './types'\n\nconst DEFAULT_COUNTS: Record<ActivitySource, number> = Object.freeze({\n  record: 0,\n  chat: 0,\n  writing: 0,\n})\n\nfunction formatDayKey(timestamp: number, timeZone?: string) {\n  return new Intl.DateTimeFormat('en-CA', {\n    timeZone,\n    year: 'numeric',\n    month: '2-digit',\n    day: '2-digit',\n  }).format(new Date(timestamp))\n}\n\nfunction cloneCounts(): Record<ActivitySource, number> {\n  return {\n    record: DEFAULT_COUNTS.record,\n    chat: DEFAULT_COUNTS.chat,\n    writing: DEFAULT_COUNTS.writing,\n  }\n}\n\nexport function summarizeActivityEntries(entries: ActivityEntry[], options: { timeZone?: string } = {}): ActivityDaySummary[] {\n  const { timeZone } = options\n  const dayMap = new Map<string, ActivityDaySummary>()\n\n  for (const entry of entries) {\n    const day = formatDayKey(entry.timestamp, timeZone)\n\n    if (!dayMap.has(day)) {\n      dayMap.set(day, {\n        day,\n        totalCount: 0,\n        counts: cloneCounts(),\n        entries: [],\n      })\n    }\n\n    const summary = dayMap.get(day)\n    if (!summary) continue\n\n    summary.totalCount += 1\n    summary.counts[entry.source] += 1\n    summary.entries.push(entry)\n  }\n\n  return Array.from(dayMap.values()).sort((a, b) => a.day.localeCompare(b.day))\n}\n\nfunction shiftDay(day: string, amount: number) {\n  const date = new Date(`${day}T00:00:00Z`)\n  date.setUTCDate(date.getUTCDate() + amount)\n  return date.toISOString().slice(0, 10)\n}\n\nexport function buildActivityHeatmap(\n  summaries: ActivityDaySummary[],\n  options: { startDate: string; endDate: string }\n): { weeks: ActivityHeatmapWeek[] } {\n  const { startDate, endDate } = options\n  const summaryMap = new Map(summaries.map(summary => [summary.day, summary]))\n  const days: ActivityDaySummary[] = []\n\n  let currentDay = startDate\n  while (currentDay <= endDate) {\n    const summary = summaryMap.get(currentDay)\n    days.push(summary || {\n      day: currentDay,\n      totalCount: 0,\n      counts: cloneCounts(),\n      entries: [],\n    })\n    currentDay = shiftDay(currentDay, 1)\n  }\n\n  const weeks: ActivityHeatmapWeek[] = []\n  for (let index = 0; index < days.length; index += 7) {\n    weeks.push({\n      days: days.slice(index, index + 7),\n    })\n  }\n\n  return {\n    weeks,\n  }\n}\n"
  },
  {
    "path": "src/lib/activity/events.ts",
    "content": "const DEFAULT_WRITING_SESSION_WINDOW_MS = 30 * 60 * 1000\n\nexport function shouldCreateWritingSession(\n  previousTimestamp: number | undefined,\n  nextTimestamp: number,\n  sessionWindowMs = DEFAULT_WRITING_SESSION_WINDOW_MS\n) {\n  if (!previousTimestamp) {\n    return true\n  }\n\n  return nextTimestamp - previousTimestamp > sessionWindowMs\n}\n\nexport function truncateActivityText(value: string | undefined, maxLength = 120) {\n  if (!value) return ''\n\n  const normalized = value.replace(/\\s+/g, ' ').trim()\n  if (normalized.length <= maxLength) {\n    return normalized\n  }\n\n  return `${normalized.slice(0, maxLength - 1)}...`\n}\n"
  },
  {
    "path": "src/lib/activity/index.ts",
    "content": "import { endOfWeek, format, startOfWeek, subWeeks } from 'date-fns'\n\nimport { getAllActivityEvents } from '@/db/activity'\nimport { buildActivityHeatmap, summarizeActivityEntries } from './aggregate'\nimport type { ActivityCalendarData, ActivityDaySummary, ActivityEntry, ActivityHeatmapWeek } from './types'\n\nfunction getBrowserTimeZone() {\n  return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'\n}\n\nfunction getDefaultRange() {\n  const today = new Date()\n  const startDate = startOfWeek(subWeeks(today, 25), { weekStartsOn: 0 })\n  const endDate = endOfWeek(today, { weekStartsOn: 0 })\n\n  return {\n    startDate: format(startDate, 'yyyy-MM-dd'),\n    endDate: format(endDate, 'yyyy-MM-dd'),\n  }\n}\n\nasync function loadActivityEntries(): Promise<ActivityEntry[]> {\n  const events = await getAllActivityEvents()\n\n  return events.map(event => ({\n    id: `${event.source}-${event.id}`,\n    source: event.source,\n    timestamp: event.createdAt,\n    title: event.title,\n    description: event.description ?? undefined,\n    path: event.path ?? undefined,\n    tagId: event.tagId ?? undefined,\n  }))\n}\n\nfunction buildTotals(days: ActivityDaySummary[]) {\n  return days.reduce((totals, day) => {\n    totals.totalCount += day.totalCount\n    totals.recordCount += day.counts.record\n    totals.chatCount += day.counts.chat\n    totals.writingCount += day.counts.writing\n    if (day.totalCount > 0) {\n      totals.activeDays += 1\n    }\n    return totals\n  }, {\n    totalCount: 0,\n    activeDays: 0,\n    recordCount: 0,\n    chatCount: 0,\n    writingCount: 0,\n  })\n}\n\nexport async function loadActivityCalendarData(): Promise<ActivityCalendarData> {\n  const timeZone = getBrowserTimeZone()\n  const { startDate, endDate } = getDefaultRange()\n\n  let entries: ActivityEntry[] = []\n\n  try {\n    entries = await loadActivityEntries()\n  } catch (error) {\n    console.error('Failed to load activity events:', error)\n  }\n\n  const days = summarizeActivityEntries(entries, { timeZone }) as ActivityDaySummary[]\n  const heatmap = buildActivityHeatmap(days, { startDate, endDate }) as {\n    weeks: ActivityHeatmapWeek[]\n  }\n\n  return {\n    timeZone,\n    startDate,\n    endDate,\n    generatedAt: Date.now(),\n    totals: buildTotals(days),\n    days,\n    weeks: heatmap.weeks,\n  }\n}\n"
  },
  {
    "path": "src/lib/activity/types.ts",
    "content": "export type ActivitySource = 'record' | 'chat' | 'writing'\n\nexport interface ActivityEntry {\n  id: string\n  source: ActivitySource\n  timestamp: number\n  title: string\n  description?: string\n  path?: string\n  tagId?: number\n  meta?: Record<string, string | number | boolean | null | undefined>\n}\n\nexport interface ActivityDaySummary {\n  day: string\n  totalCount: number\n  counts: Record<ActivitySource, number>\n  entries: ActivityEntry[]\n}\n\nexport interface ActivityHeatmapWeek {\n  days: ActivityDaySummary[]\n}\n\nexport interface ActivityCalendarData {\n  timeZone: string\n  startDate: string\n  endDate: string\n  generatedAt: number\n  totals: {\n    totalCount: number\n    activeDays: number\n    recordCount: number\n    chatCount: number\n    writingCount: number\n  }\n  days: ActivityDaySummary[]\n  weeks: ActivityHeatmapWeek[]\n}\n"
  },
  {
    "path": "src/lib/agent/agent-handler.ts",
    "content": "import { ReActAgent, ReActConfig } from './react'\nimport { ToolCall, ReActStep } from './types'\nimport useChatStore from '@/stores/chat'\nimport { skillManager } from '@/lib/skills'\nimport { useSkillsStore } from '@/stores/skills'\nimport { reloadMcpTools } from './tools'\nimport OpenAI from 'openai'\n\nexport interface AgentHandlerConfig {\n  activeChatId?: number\n  onThought?: (thought: string) => void\n  onAction?: (action: string, params: Record<string, any>) => void\n  onObservation?: (observation: string) => void\n  onComplete?: (result: string, steps?: any[], stopped?: boolean) => void\n  onError?: (error: string) => void\n  onFinalAnswerRender?: (markdownContent: string) => void  // 当检测到 Final Answer 时立即渲染 Markdown\n  formatAutoFinalAnswer?: (key: string, values?: Record<string, string>) => string\n  requestConfirmation?: (toolName: string, params: Record<string, any>) => Promise<boolean>\n  currentQuote?: {\n    fileName: string\n    startLine: number\n    endLine: number\n    from: number\n    to: number\n    fullContent?: string\n  }\n}\n\nexport class AgentHandler {\n  private agent: ReActAgent | null = null\n  private config: AgentHandlerConfig\n\n  constructor(config: AgentHandlerConfig) {\n    this.config = config\n  }\n\n  async execute(\n    userInput: string,\n    contextOrMessages?: string | OpenAI.Chat.ChatCompletionMessageParam[],\n    imageUrls?: string[]\n  ): Promise<string> {\n    const store = useChatStore.getState()\n\n    store.resetAgentState()\n    store.setAgentState({\n      activeChatId: this.config.activeChatId,\n      isRunning: true,\n    })\n\n    // 确保 MCP Store 已初始化\n    try {\n      const { useMcpStore } = await import('@/stores/mcp')\n      const mcpStore = useMcpStore.getState()\n      if (!mcpStore.initialized) {\n        await mcpStore.initMcpData()\n      }\n    } catch (error) {\n      console.error('[Agent Handler] Failed to initialize MCP Store:', error)\n    }\n\n    // 预加载 MCP 工具\n    try {\n      await reloadMcpTools()\n    } catch (error) {\n      console.error('[Agent Handler] Failed to reload MCP tools:', error)\n    }\n\n    // 获取所有可用的 Skills（让 AI 自己选择）\n    const activeSkills = await this.getAvailableSkills()\n    // 获取 Skills 的详细信息用于 UI 显示\n    const skillsInfo = await this.getSkillsInfo()\n    // 将加载的 Skills 信息存储到状态中，用于 UI 显示\n    store.setAgentState({ loadedSkills: skillsInfo })\n\n    const reactConfig: ReActConfig = {\n      maxIterations: 15,\n      activeSkills,\n      onIterationStart: () => {\n        // 在新迭代开始时，将完整的 ReAct 循环保存到历史，然后清空当前状态\n        const currentState = useChatStore.getState()\n        if (currentState.agentState.currentThought ||\n            currentState.agentState.currentAction ||\n            currentState.agentState.currentObservation) {\n          // 检查是否是 Final Answer - 如果是，不添加到 completedSteps，直接清空\n          const isFinalAnswer = currentState.agentState.currentThought.includes('Final Answer:') ||\n                               currentState.agentState.currentThought.includes('Final Answer：') ||\n                               currentState.agentState.currentThought.includes('最终答案')\n\n          if (isFinalAnswer) {\n            // Final Answer 不添加到步骤历史，直接清空状态（它会作为 result 在正文中显示）\n            store.setAgentState({\n              currentThought: '',\n              currentAction: undefined,\n              currentObservation: undefined,\n              currentStepStartTime: undefined,\n            })\n            return\n          }\n\n          // 解析当前动作\n          let action = undefined\n          if (currentState.agentState.currentAction) {\n            const match = currentState.agentState.currentAction.match(/^(\\w+)\\((.*)\\)$/)\n            if (match) {\n              try {\n                action = {\n                  tool: match[1],\n                  params: match[2] ? JSON.parse(match[2]) : {}\n                }\n              } catch {\n                // 解析失败，忽略\n              }\n            }\n          }\n\n          // 计算步骤耗时\n          const duration = currentState.agentState.currentStepStartTime\n            ? Date.now() - currentState.agentState.currentStepStartTime\n            : undefined\n\n          // 创建完整的步骤\n          const completedStep: ReActStep = {\n            thought: currentState.agentState.currentThought,\n            action: action,\n            observation: currentState.agentState.currentObservation,\n            duration\n          }\n\n          const newHistory = [...currentState.agentState.thoughtHistory, currentState.agentState.currentThought]\n          const newCompletedSteps = [...currentState.agentState.completedSteps, completedStep]\n          store.setAgentState({\n            thoughtHistory: newHistory,\n            completedSteps: newCompletedSteps,\n            currentThought: '',\n            currentAction: undefined,\n            currentObservation: undefined,\n            currentStepStartTime: Date.now(),  // 记录新步骤的开始时间\n            isThinking: true  // 标记正在等待 AI 生成新的思考\n          })\n        }\n      },\n      onThought: (thought: string) => {\n        // 流式输出时只更新当前思考，不保存到历史\n        store.setAgentState({\n          currentThought: thought,\n          isThinking: false  // 开始输出内容，取消思考状态\n        })\n        this.config.onThought?.(thought)\n      },\n      onAction: (action, params) => {\n        store.setAgentState({ currentAction: `${action}(${JSON.stringify(params)})` })\n        this.config.onAction?.(action, params)\n      },\n      onObservation: (observation) => {\n        store.setAgentState({ currentObservation: observation })\n        this.config.onObservation?.(observation)\n      },\n      onToolCall: (toolCall: ToolCall) => {\n        // 获取最新的 store 状态\n        const currentState = useChatStore.getState()\n        const existingCall = currentState.agentState.toolCalls.find(c => c.id === toolCall.id)\n        if (existingCall) {\n          currentState.updateAgentToolCall(toolCall.id, toolCall)\n        } else {\n          currentState.addAgentToolCall(toolCall)\n        }\n      },\n      onSkillsSelected: (skillIds: string[]) => {\n        // 当 AI 选择 Skills 后，更新状态\n        store.setAgentState({ selectedSkills: skillIds })\n      },\n      onFinalAnswerRender: (markdownContent: string) => {\n        // 检测到 Final Answer 时，触发外部渲染\n        this.config.onFinalAnswerRender?.(markdownContent)\n      },\n      formatAutoFinalAnswer: this.config.formatAutoFinalAnswer,\n      requestConfirmation: this.config.requestConfirmation,\n      currentQuote: this.config.currentQuote,\n    }\n\n    // 在开始执行前设置当前步骤的开始时间（确保第一次思考也有耗时）\n    store.setAgentState({\n      isThinking: true,\n      currentStepStartTime: Date.now()\n    })\n\n    this.agent = new ReActAgent(reactConfig)\n\n    try {\n      const result = await this.agent.run(userInput, contextOrMessages, imageUrls)\n      store.setAgentState({ isRunning: false })\n\n      // 获取完整的 ReAct 步骤\n      const steps = this.agent.getSteps()\n      this.config.onComplete?.(result, steps, false)\n      return result\n    } catch (error) {\n      store.setAgentState({ isRunning: false })\n\n      // 检查是否是用户终止\n      if (error instanceof Error && error.message === 'USER_STOPPED') {\n        // 获取已产生的步骤\n        const steps = this.agent.getSteps()\n        // 调用 onComplete，传入空结果和已产生的步骤，标记为已停止\n        this.config.onComplete?.('', steps, true)\n        return ''\n      }\n\n      const errorMessage = error instanceof Error ? error.message : String(error)\n      this.config.onError?.(errorMessage)\n      throw error\n    }\n  }\n\n  stop() {\n    if (this.agent) {\n      this.agent.stop()\n      // 不立即清空 agent，等待 run 方法中的错误处理完成\n      // 不调用 resetAgentState，让 onComplete 回调保存已产生的内容\n    }\n  }\n\n  /**\n   * 获取所有可用的 Skills（只返回元数据，让 AI 先选择）\n   */\n  private async getAvailableSkills(): Promise<string[]> {\n    const skillsStore = useSkillsStore.getState()\n\n    // 如果 Skills 功能未启用，返回空数组\n    if (!skillsStore.enabled) {\n      return []\n    }\n\n    // 如果未启用自动匹配，返回空数组\n    if (!skillsStore.autoMatch) {\n      return []\n    }\n\n    try {\n      // 确保 Skill 管理器已初始化（initSkills 会处理重复初始化）\n      await skillsStore.initSkills()\n\n      // 获取所有已启用的 Skills\n      const enabledSkills = await skillManager.getEnabledSkills()\n\n      // 返回所有已启用 Skill 的 ID 列表\n      // 注意：这里只传递 ID，具体内容在 formatSkillsInstructions 中按需加载\n      const skillIds = enabledSkills.map(skill => skill.metadata.id)\n      return skillIds\n    } catch (error) {\n      console.error('[Skills Debug] Failed to get skills:', error)\n      return []\n    }\n  }\n\n  /**\n   * 获取 Skills 的详细信息用于 UI 显示\n   */\n  private async getSkillsInfo(): Promise<Array<{ id: string; name: string; description?: string }>> {\n    const skillsStore = useSkillsStore.getState()\n\n    // 如果 Skills 功能未启用，返回空数组\n    if (!skillsStore.enabled || !skillsStore.autoMatch) {\n      return []\n    }\n\n    try {\n      // 确保 Skill 管理器已初始化\n      await skillsStore.initSkills()\n      const enabledSkills = await skillManager.getEnabledSkills()\n\n      return enabledSkills.map(skill => ({\n        id: skill.metadata.id,\n        name: skill.metadata.name,\n        description: skill.metadata.description\n      }))\n    } catch (error) {\n      console.error('[Skills Debug] Failed to get skills info:', error)\n      return []\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/agent/auto-final-answer.ts",
    "content": "export interface AutoFinalAnswerDescriptor {\n  key: string\n  values: Record<string, string>\n  fallback: string\n}\n\ninterface AutoFinalAnswerInput {\n  toolName: string\n  params: Record<string, any>\n  observation: string\n}\n\nconst CONTINUATION_FAILURE_PATTERNS = [\n  /^$/,\n  /^请求失败:/,\n  /^error:/i,\n  /AI 服务暂时不可用/,\n  /Unable to complete task/i,\n]\n\nexport function shouldRecoverWithAutoFinalAnswer(thought: string): boolean {\n  const normalized = thought.trim()\n  return CONTINUATION_FAILURE_PATTERNS.some((pattern) => pattern.test(normalized))\n}\n\nexport function getAutoFinalAnswerDescriptor(\n  input: AutoFinalAnswerInput\n): AutoFinalAnswerDescriptor | null {\n  const { toolName, params, observation } = input\n\n  if (toolName !== 'create_file' || !observation.startsWith('成功创建文件:')) {\n    return null\n  }\n\n  const rawFileName = typeof params.fileName === 'string' && params.fileName.trim()\n    ? params.fileName.trim().split('/').pop() || params.fileName.trim()\n    : 'untitled'\n  const isMarkdown = /\\.md$/i.test(rawFileName)\n\n  if (isMarkdown) {\n    return {\n      key: 'record.chat.input.agent.autoFinal.createNote',\n      values: {\n        name: rawFileName,\n      },\n      fallback: `Created note \"${rawFileName}\".`,\n    }\n  }\n\n  return {\n    key: 'record.chat.input.agent.autoFinal.createFile',\n    values: {\n      name: rawFileName,\n    },\n    fallback: `Created file \"${rawFileName}\".`,\n  }\n}\n"
  },
  {
    "path": "src/lib/agent/i18n.ts",
    "content": "export const agentTranslations = {\n  en: {\n    modeSelect: {\n      chat: 'Chat',\n      agent: 'Agent',\n    },\n    agent: {\n      running: 'Agent Running',\n      thinking: 'Thinking',\n      acting: 'Acting',\n      observation: 'Observation',\n      toolCalls: 'Tool Calls',\n      confirmation: {\n        title: 'Confirm Action',\n        description: 'The agent wants to perform the following action. Please confirm to continue.',\n        tool: 'Tool',\n        parameters: 'Parameters',\n        cancel: 'Cancel',\n        confirm: 'Confirm',\n      },\n    },\n  },\n  'zh-CN': {\n    modeSelect: {\n      chat: '对话',\n      agent: '智能体',\n    },\n    agent: {\n      running: 'Agent 运行中',\n      thinking: '思考中',\n      acting: '执行中',\n      observation: '观察结果',\n      toolCalls: '工具调用',\n      confirmation: {\n        title: '确认操作',\n        description: 'Agent 想要执行以下操作，请确认后继续。',\n        tool: '工具',\n        parameters: '参数',\n        cancel: '取消',\n        confirm: '确认',\n      },\n    },\n  },\n}\n"
  },
  {
    "path": "src/lib/agent/parse-action-input.ts",
    "content": "function closeJsonStructures(jsonStr: string): string {\n  const stack: string[] = []\n  let inString = false\n  let escapeNext = false\n\n  for (let i = 0; i < jsonStr.length; i++) {\n    const char = jsonStr[i]\n\n    if (escapeNext) {\n      escapeNext = false\n      continue\n    }\n\n    if (char === '\\\\') {\n      escapeNext = true\n      continue\n    }\n\n    if (char === '\"') {\n      inString = !inString\n      if (!inString && stack[stack.length - 1] === '\"') {\n        stack.pop()\n      } else if (inString) {\n        stack.push('\"')\n      }\n      continue\n    }\n\n    if (!inString) {\n      if (char === '{' || char === '[') {\n        stack.push(char)\n      } else if (char === '}' && stack[stack.length - 1] === '{') {\n        stack.pop()\n      } else if (char === ']' && stack[stack.length - 1] === '[') {\n        stack.pop()\n      }\n    }\n  }\n\n  if (inString) {\n    jsonStr += '\"'\n  }\n\n  while (stack.length > 0) {\n    const open = stack.pop()\n    if (open === '\"') {\n      jsonStr += '\"'\n    } else if (open === '[') {\n      jsonStr += ']'\n    } else if (open === '{') {\n      jsonStr += '}'\n    }\n  }\n\n  return jsonStr\n}\n\nfunction escapeLiteralNewlinesInStrings(jsonStr: string): string {\n  let result = ''\n  let inString = false\n  let escapeNext = false\n\n  for (let i = 0; i < jsonStr.length; i++) {\n    const char = jsonStr[i]\n\n    if (escapeNext) {\n      result += char\n      escapeNext = false\n      continue\n    }\n\n    if (char === '\\\\') {\n      result += char\n      escapeNext = true\n      continue\n    }\n\n    if (char === '\"') {\n      inString = !inString\n      result += char\n      continue\n    }\n\n    if (inString && char === '\\n') {\n      result += '\\\\n'\n      continue\n    }\n\n    if (inString && char === '\\r') {\n      result += '\\\\r'\n      continue\n    }\n\n    result += char\n  }\n\n  return result\n}\n\nexport function parseActionInputJson(jsonStr: string): Record<string, any> | null {\n  try {\n    return JSON.parse(jsonStr)\n  } catch {\n    const repaired = closeJsonStructures(escapeLiteralNewlinesInStrings(jsonStr))\n\n    try {\n      return JSON.parse(repaired)\n    } catch {\n      return null\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/agent/react-diff-helpers.ts",
    "content": "/**\n * 替换指定行范围的内容\n * 用于在确认对话框中预览修改效果\n */\nexport function replaceLinesInRange(\n  content: string,\n  startLine: number,\n  endLine: number,\n  newLines: string[]\n): string {\n  const lines = content.split('\\n')\n\n  // 容错处理：如果 startLine > endLine，自动交换\n  let actualStartLine = startLine\n  let actualEndLine = endLine\n  if (startLine > endLine) {\n    actualStartLine = endLine\n    actualEndLine = startLine\n  }\n\n  // 将行号转换为数组索引（从 0 开始）\n  const startIndex = actualStartLine - 1\n  const endIndex = actualEndLine - 1\n\n  // 验证行号范围\n  if (startIndex < 0 || endIndex >= lines.length) {\n    throw new Error(`无效的行号范围: ${startLine}-${endLine}，文件共 ${lines.length} 行`)\n  }\n\n  // 替换指定行\n  const before = lines.slice(0, startIndex)\n  const after = lines.slice(endIndex + 1)\n  return [...before, ...newLines, ...after].join('\\n')\n}\n\n/**\n * 搜索并替换内容（支持正则表达式）\n * 用于在确认对话框中预览修改效果\n */\nexport function searchReplaceContent(\n  content: string,\n  searchPattern: string,\n  replacement: string,\n  useRegex: boolean,\n  caseSensitive: boolean,\n  replaceAll: boolean\n): string {\n  try {\n    let pattern = searchPattern\n    const flags = caseSensitive ? 'g' : 'gi'\n\n    if (!useRegex) {\n      // 非正则模式，转义特殊字符\n      pattern = pattern.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n    }\n\n    const regex = new RegExp(pattern, replaceAll ? flags : flags.replace('g', ''))\n\n    return content.replace(regex, replacement)\n  } catch (error) {\n    throw new Error(`搜索替换失败: ${error}`)\n  }\n}\n\n/**\n * 在指定行号后插入内容\n * 用于在确认对话框中预览修改效果\n */\nexport function insertLinesAtPosition(\n  content: string,\n  afterLine: number,\n  newLines: string[]\n): string {\n  const lines = content.split('\\n')\n\n  // 验证行号\n  if (afterLine < 0 || afterLine > lines.length) {\n    throw new Error(`无效的行号: ${afterLine}，文件共 ${lines.length} 行`)\n  }\n\n  // 在指定行后插入内容\n  const before = lines.slice(0, afterLine)\n  const after = lines.slice(afterLine)\n\n  return [...before, ...newLines, ...after].join('\\n')\n}\n\n/**\n * 删除指定行范围\n * 用于在确认对话框中预览修改效果\n */\nexport function deleteLinesInRange(\n  content: string,\n  startLine: number,\n  endLine: number\n): string {\n  const lines = content.split('\\n')\n\n  // 容错处理：如果 startLine > endLine，自动交换\n  let actualStartLine = startLine\n  let actualEndLine = endLine\n  if (startLine > endLine) {\n    actualStartLine = endLine\n    actualEndLine = startLine\n  }\n\n  // 将行号转换为数组索引（从 0 开始）\n  const startIndex = actualStartLine - 1\n  const endIndex = actualEndLine - 1\n\n  // 验证行号范围\n  if (startIndex < 0 || endIndex >= lines.length) {\n    throw new Error(`无效的行号范围: ${startLine}-${endLine}，文件共 ${lines.length} 行`)\n  }\n\n  // 删除指定行\n  const before = lines.slice(0, startIndex)\n  const after = lines.slice(endIndex + 1)\n\n  return [...before, ...after].join('\\n')\n}\n"
  },
  {
    "path": "src/lib/agent/react.ts",
    "content": "import { ReActStep, ToolCall, ToolResult } from './types'\nimport { getToolByName, getToolDescriptions } from './tools'\nimport { skillManager } from '@/lib/skills'\nimport useChatStore from '@/stores/chat'\nimport { isLinkedFolder } from '@/lib/files'\nimport {\n  getAutoFinalAnswerDescriptor,\n  shouldRecoverWithAutoFinalAnswer,\n} from './auto-final-answer'\nimport { parseActionInputJson } from './parse-action-input'\nimport {\n  IntentPolicy,\n  deriveIntentPolicy,\n  evaluateIntentAwareToolPolicy,\n  formatIntentPolicyForPrompt,\n} from './tool-policy'\nimport OpenAI from 'openai'\n\nfunction buildIterationUserMessage(\n  iteration: number,\n  userInput: string,\n  lastObservation?: string\n): string {\n  if (iteration <= 1) {\n    return `This is iteration ${iteration}, please give your Thought and Action (or Final Answer):\\n\\nUser Request: ${userInput}`\n  }\n\n  return `## User Request\n${userInput}\n\n## Previous Step Result\n${lastObservation || 'No previous result'}\n\n---\nKeep working toward the user request above.\nIf the task is completed, respond with Final Answer.\nIf you need to continue, provide your next Thought and Action.`\n}\n\nfunction normalizeLinkedCandidate(candidate: unknown): string {\n  return typeof candidate === 'string' ? candidate.trim() : ''\n}\n\nfunction getLinkedFileName(path: unknown): string {\n  const normalized = normalizeLinkedCandidate(path)\n  return normalized.split('/').pop() || normalized\n}\n\nfunction matchesLinkedFileCandidate(\n  candidate: unknown,\n  linkedResource: { relativePath?: string; name?: string; path?: string }\n): boolean {\n  const normalized = normalizeLinkedCandidate(candidate)\n  if (!normalized) {\n    return false\n  }\n\n  const linkedPaths = new Set([\n    linkedResource.relativePath,\n    linkedResource.name,\n    linkedResource.path,\n    getLinkedFileName(linkedResource.relativePath),\n    getLinkedFileName(linkedResource.path),\n  ].filter(Boolean))\n\n  return linkedPaths.has(normalized) || linkedPaths.has(getLinkedFileName(normalized))\n}\n\nfunction shouldBlockRedundantLinkedFileRead(\n  toolName: string,\n  params: Record<string, any>,\n  linkedResource: { relativePath?: string; name?: string; path?: string }\n): boolean {\n  if (toolName === 'read_markdown_file') {\n    return typeof params.filePath === 'string' && matchesLinkedFileCandidate(params.filePath, linkedResource)\n  }\n\n  if (toolName === 'read_markdown_files_batch') {\n    if (!Array.isArray(params.filePaths) || params.filePaths.length === 0) {\n      return false\n    }\n\n    return params.filePaths.every((filePath: unknown) =>\n      typeof filePath === 'string' && matchesLinkedFileCandidate(filePath, linkedResource)\n    )\n  }\n\n  if (toolName === 'check_folder_exists') {\n    return typeof params.folderPath === 'string' && matchesLinkedFileCandidate(params.folderPath, linkedResource)\n  }\n\n  return false\n}\n\nfunction isExplicitTagOrMarkIntent(userInput: string): boolean {\n  return /标签|標籤|tag|记录|紀錄|mark|摘录|摘錄|收集箱|inbox/i.test(userInput)\n}\n\nfunction shouldKeepFocusOnLinkedNote(\n  userInput: string,\n  linkedResource: { relativePath?: string; name?: string; path?: string },\n  toolName: string\n): boolean {\n  const tagMarkToolNames = new Set([\n    'list_tags',\n    'search_tags',\n    'read_marks',\n    'search_marks',\n    'search_all_marks',\n  ])\n\n  if (!tagMarkToolNames.has(toolName) || isExplicitTagOrMarkIntent(userInput)) {\n    return false\n  }\n\n  const linkedPath = linkedResource.relativePath || linkedResource.path || linkedResource.name || ''\n  return /\\.md$/i.test(linkedPath)\n}\n\nfunction isSuccessfulObservation(observation?: string): boolean {\n  if (!observation) {\n    return false\n  }\n\n  return !observation.includes('失败') &&\n    !observation.includes('错误') &&\n    !observation.includes('阻止')\n}\n\nfunction shouldBlockRepeatedNoteExploration(\n  toolName: string,\n  params: Record<string, any>,\n  steps: ReActStep[]\n): boolean {\n  const hasSuccessfulBatchRead = steps.some((step) =>\n    step.action?.tool === 'read_markdown_files_batch' &&\n    isSuccessfulObservation(step.observation) &&\n    step.observation?.includes('成功读取')\n  )\n\n  if (toolName === 'list_markdown_files' && hasSuccessfulBatchRead) {\n    return true\n  }\n\n  if (toolName !== 'read_markdown_files_batch') {\n    return false\n  }\n\n  const currentParams = JSON.stringify(params || {})\n  return steps.some((step) =>\n    step.action?.tool === 'read_markdown_files_batch' &&\n    JSON.stringify(step.action?.params || {}) === currentParams &&\n    isSuccessfulObservation(step.observation)\n  )\n}\n\nexport interface ReActConfig {\n  maxIterations: number\n  onThought?: (thought: string) => void\n  onAction?: (action: string, params: Record<string, any>) => void\n  onObservation?: (observation: string) => void\n  onToolCall?: (toolCall: ToolCall) => void\n  onIterationStart?: () => void\n  onSkillsSelected?: (skillIds: string[]) => void  // 当 AI 选择 Skills 时调用\n  onFinalAnswerRender?: (markdownContent: string) => void  // 当检测到 Final Answer 时立即渲染 Markdown\n  formatAutoFinalAnswer?: (key: string, values?: Record<string, string>) => string\n  requestConfirmation?: (toolName: string, params: Record<string, any>, context?: {\n    originalContent?: string\n    modifiedContent?: string\n    filePath?: string\n  }) => Promise<boolean>\n  activeSkills?: string[]  // 当前激活的 Skills\n  currentQuote?: {\n    fileName: string\n    startLine: number\n    endLine: number\n    from: number\n    to: number\n    fullContent?: string\n  }\n}\n\nexport class ReActAgent {\n  private config: ReActConfig\n  private steps: ReActStep[] = []\n  private currentIteration = 0\n  private toolCallCounter = 0\n  private stopped = false\n  private abortController: AbortController | null = null\n  private selectedSkills: Set<string> = new Set() // 记录 AI 选择的 Skills\n  private currentUserInput = ''\n  private intentPolicy: IntentPolicy = {\n    allowWrite: false,\n    allowDestructive: false,\n    allowExecute: false,\n  }\n\n  constructor(config: ReActConfig) {\n    this.config = config\n    if (!this.config.maxIterations) {\n      this.config.maxIterations = 15\n    }\n  }\n\n  stop() {\n    this.stopped = true\n    // 终止所有正在进行的异步操作\n    if (this.abortController) {\n      this.abortController.abort()\n      this.abortController = null\n    }\n  }\n\n  isStopped(): boolean {\n    return this.stopped\n  }\n\n  async run(\n    userInput: string,\n    contextOrMessages?: string | OpenAI.Chat.ChatCompletionMessageParam[],\n    imageUrls?: string[]\n  ): Promise<string> {\n    this.steps = []\n    this.currentIteration = 0\n    this.toolCallCounter = 0\n    this.stopped = false\n    this.selectedSkills.clear()\n    this.currentUserInput = userInput\n    this.intentPolicy = deriveIntentPolicy(userInput)\n    // 创建新的 AbortController\n    this.abortController = new AbortController()\n\n    let finalAnswer = ''\n\n    // 检测 contextOrMessages 的类型\n    const isMessagesArray = Array.isArray(contextOrMessages)\n    const contextString = isMessagesArray ? undefined : contextOrMessages as string | undefined\n    const messagesArray = isMessagesArray ? contextOrMessages as OpenAI.Chat.ChatCompletionMessageParam[] : undefined\n\n    while (this.currentIteration < this.config.maxIterations) {\n      // 检查是否已停止\n      if (this.stopped) {\n        // 返回特殊标记表示被用户终止，但保留已产生的步骤\n        throw new Error('USER_STOPPED')\n      }\n\n      this.currentIteration++\n\n      // 在新迭代开始时，通知保存上一次的思考到历史\n      if (this.currentIteration > 1) {\n        this.config.onIterationStart?.()\n      }\n\n      // 每次迭代都重新构建系统提示词，因为 Skills 指令依赖于当前迭代次数\n      const systemPrompt = await this.buildSystemPrompt()\n\n      const thought = await this.think(userInput, contextString, messagesArray, systemPrompt, imageUrls)\n\n      // 再次检查是否已停止\n      if (this.stopped) {\n        // 返回特殊标记表示被用户终止，但保留已产生的步骤\n        throw new Error('USER_STOPPED')\n      }\n\n      const lastCompletedStep = this.steps[this.steps.length - 1]\n      if (lastCompletedStep?.action && shouldRecoverWithAutoFinalAnswer(thought)) {\n        const descriptor = getAutoFinalAnswerDescriptor({\n          toolName: lastCompletedStep.action.tool,\n          params: lastCompletedStep.action.params,\n          observation: lastCompletedStep.observation || '',\n        })\n        if (descriptor) {\n          finalAnswer = this.config.formatAutoFinalAnswer?.(descriptor.key, descriptor.values) || descriptor.fallback\n          break\n        }\n      }\n\n      // 检查是否包含 Final Answer（支持多种格式，包括换行的情况）\n      // 处理 \"Action: Final\\nAnswer:\" 的特殊情况\n      const normalizedThought = thought.replace(/\\s+/g, ' ')\n      const hasFinalAnswer = normalizedThought.includes('Final Answer:') ||\n                             normalizedThought.includes('Final Answer：') ||\n                             normalizedThought.includes('最终答案') ||\n                             /Action:\\s*Final\\s*Answer/i.test(thought)\n\n      if (hasFinalAnswer) {\n        // 直接提取 Final Answer 后面的内容作为 Markdown 格式返回\n        if (thought.includes('Final Answer:')) {\n          finalAnswer = thought.split('Final Answer:')[1].trim()\n        } else if (thought.includes('Final Answer：')) {\n          finalAnswer = thought.split('Final Answer：')[1].trim()\n        } else if (thought.includes('最终答案')) {\n          finalAnswer = thought.split('最终答案')[1].trim()\n        } else if (/Action:\\s*Final\\s*Answer:\\s*([\\s\\S]*)/i.test(thought)) {\n          // 处理 \"Action: Final\\nAnswer:\" 的情况\n          const match = thought.match(/Action:\\s*Final\\s*Answer:\\s*([\\s\\S]*)/i)\n          if (match) {\n            finalAnswer = match[1].trim()\n          }\n        } else if (/Final Answer:\\s*([\\s\\S]*)/i.test(thought)) {\n          // 处理 \"Final Answer:\\n...\" 多行内容的情况\n          const match = thought.match(/Final Answer:\\s*([\\s\\S]*)/i)\n          if (match) {\n            finalAnswer = match[1].trim()\n          }\n        }\n\n        const finalAnswerValidation = this.validateFinalAnswerReadiness(userInput, finalAnswer || '')\n        if (!finalAnswerValidation.ok) {\n          const observation = finalAnswerValidation.reason || '最终答案校验未通过，请继续执行实际工具。'\n          this.config.onObservation?.(observation)\n          this.steps.push({\n            thought,\n            action: undefined,\n            observation,\n          })\n          finalAnswer = ''\n          continue\n        }\n        break\n      }\n\n      // 检查是否是纯思考而没有 Action（说明 AI 认为任务已完成但忘记用 Final Answer 格式）\n      if (!thought.includes('Action:') && thought.includes('Thought:') && this.currentIteration > 1) {\n        // 如果只有 Thought 没有 Action，且这是第二次以后的迭代，可能是 AI 忘记格式\n        // 将整个 thought 作为最终答案\n        const thoughtContent = thought.replace(/Thought:\\s*/i, '').trim()\n        if (thoughtContent.length > 0 && !thoughtContent.includes('Action:')) {\n          finalAnswer = thoughtContent\n          break\n        }\n      }\n\n      const action = this.parseAction(thought)\n      if (!action) {\n        if (thought.includes('Action:')) {\n          const observation = 'Action Input JSON 无法解析。请保持动作不变，并只重新输出一次有效的 JSON 参数。'\n          this.config.onObservation?.(observation)\n          this.steps.push({\n            thought,\n            action: undefined,\n            observation,\n          })\n          continue\n        }\n\n        // 无法解析 Action，尝试从 thought 中提取答案\n        // 检查是否 AI 想直接回答但忘记使用 Final Answer 格式\n        const thoughtContent = thought.replace(/Thought:\\s*/i, '').trim()\n        if (thoughtContent && thoughtContent.length > 10 && !thoughtContent.includes('Action:')) {\n          // 看起来 AI 想直接回答，提取内容作为答案\n          finalAnswer = thoughtContent\n          break\n        }\n\n        // 如果是第一次迭代，可能是 AI 没理解用户意图\n        // 尝试让 AI 直接回答而不是调用工具\n        if (this.currentIteration === 1) {\n          finalAnswer = thoughtContent || '抱歉，我不太理解您的需求。您能详细说明一下吗？'\n          break\n        }\n\n        // 多次迭代后仍然失败，给出提示\n        finalAnswer = thoughtContent || '抱歉，我遇到了一些问题。您能换种方式说明一下您的需求吗？'\n        break\n      }\n\n      // 检测重复操作\n      const lastStep = this.steps[this.steps.length - 1]\n      if (lastStep && lastStep.action) {\n        // 检查是否是相同的工具和参数\n        const isSameTool = lastStep.action.tool === action.tool\n        const isSameParams = JSON.stringify(lastStep.action.params) === JSON.stringify(action.params)\n        const lastStepWasPolicyAdjustment = this.isPolicyAdjustmentObservation(lastStep.observation)\n\n        if (isSameTool && isSameParams) {\n          if (lastStepWasPolicyAdjustment) {\n          } else {\n            // 检测到重复操作，给出警告并结束\n            console.warn(`检测到重复操作: ${action.tool}`, action.params)\n            finalAnswer = `操作已完成。${lastStep.observation}`\n            break\n          }\n        }\n\n        // 检查是否连续多次执行完全相同的操作（超过 5 次且工具和参数都相同）\n        // 只检查参数完全相同的情况，避免误判合法的批量操作\n        let sameActionCount = 0\n        for (let i = this.steps.length - 1; i >= 0; i--) {\n          const step = this.steps[i]\n          if (step.action && step.action.tool === action.tool) {\n            const stepParamsSame = JSON.stringify(step.action.params) === JSON.stringify(action.params)\n            if (stepParamsSame) {\n              sameActionCount++\n            } else {\n              break\n            }\n          } else {\n            break\n          }\n        }\n\n        if (sameActionCount >= 5) {\n          console.warn(`检测到连续多次执行相同操作: ${action.tool}, 次数: ${sameActionCount}`)\n          finalAnswer = `检测到连续多次执行相同操作，已自动停止。最后操作结果：${lastStep.observation}`\n          break\n        }\n      }\n\n      this.config.onAction?.(action.tool, action.params)\n\n      const observation = await this.act(action.tool, action.params, thought)\n\n      // 检查是否已停止\n      if (this.stopped) {\n        // 返回特殊标记表示被用户终止，但保留已产生的步骤\n        throw new Error('USER_STOPPED')\n      }\n      \n      this.config.onObservation?.(observation)\n\n      this.steps.push({\n        thought,\n        action,\n        observation,\n      })\n\n      if (observation.includes('错误') || observation.includes('失败')) {\n        if (this.currentIteration >= this.config.maxIterations - 1) {\n          finalAnswer = `执行过程中遇到问题：${observation}`\n          break\n        }\n      }\n    }\n\n    if (!finalAnswer && this.currentIteration >= this.config.maxIterations) {\n      finalAnswer = '已达到最大迭代次数，任务可能未完全完成。'\n    }\n\n    return finalAnswer || '任务执行完成。'\n  }\n\n  private async buildSystemPrompt(): Promise<string> {\n    const toolDescriptions = getToolDescriptions()\n    const skillsInstructions = this.formatSkillsInstructions()\n    const intentPolicyPrompt = formatIntentPolicyForPrompt(this.intentPolicy)\n\n    // Load user memories (preferences and knowledge)\n    let memoryPrompt = ''\n    try {\n      const { contextLoader } = await import('@/lib/context/loader')\n      // Get all memories (preferences are always included, knowledge is matched by similarity)\n      const memoryContext = await contextLoader.getContextForQuery('')  // Empty query gets all preferences\n      if (memoryContext.preferences.length > 0 || memoryContext.memory.length > 0) {\n        memoryPrompt = contextLoader.formatMemoriesForPrompt(memoryContext)\n      }\n    } catch (error) {\n      console.error('[Agent] Failed to load memories:', error)\n    }\n\n    let prompt = `You are an efficient AI agent that uses tools to help users complete tasks. Follow the ReAct framework: Thought → Action → Observation.\n\n${memoryPrompt ? `## User Memories\\n\\n${memoryPrompt}\\n` : ''}\n\n## 🚨 Important Warning: Skills Are Not Tools\n\n**You must NEVER use these formats:**\n- ❌ Action: style-detector\n- ❌ Action: skill_detector\n- ❌ Action: any_skill_name\n\n**Skills are guidance documents, NOT callable tools!**\n- Skills tell you HOW to complete tasks\n- You need to understand Skill requirements, then use **actual tools** (like create_file) to execute\n- Example: if style-detector says to write web fiction, you should Action: create_file and write in web fiction style in the content\n\n## Core Principles\n\n**Intent First**: Before using any tool, carefully analyze user's intent:\n- **Is the user asking a question?** → Give direct answer with Final Answer\n- **Is the user requesting information?** → Search/read relevant notes, then answer\n- **Is the user explicitly requesting an action?** (create, modify, delete) → Then use tools\n- **Are you unsure about user's intent?** → Ask clarifying question, don't assume\n\n**Efficiency**: Complete tasks with minimum steps, avoid unnecessary tool calls.\n**Direct Action**: If intent is clear and action is needed, execute without over-analysis.\n**Quick Finish**: Give Final Answer immediately after the task is actually complete. If the previous result shows there is still a required next step, continue with that next step instead of stopping early.\n\n## Knowledge Base Search Guide\n\nIn the \"context information\", you may see \"Knowledge Base Search Results\" section. This is from **automatic RAG search**.\n\n**If automatic search results are insufficient**, you can actively call search tools for more precise retrieval:\n\nSearch tool selection guide:\n- search_markdown_files: Use when user asks to search files (default: keyword mode, rag: semantic mode)\n- search_markdown_files + folderPath: Limit scope to specific folder\n- search_marks: Search database records under tags\n\nImportant tips:\n- Only call search tools when user explicitly requests to search/查找/搜索\n\n## 🚨 Critical: Understanding Notes vs Tags vs Marks\n\nBefore using any tools, you MUST understand the difference between these three core concepts:\n\n### 1. **Notes (笔记)** - File System Resources\n- **What**: Markdown (.md) files in the file manager\n- **Storage**: Local file system (custom workspace or default article directory)\n- **How to identify**: Tool names contain \"markdown_file\" (e.g., \"read_markdown_file\", \"list_markdown_files\")\n- **When to use**: User mentions \"notes\", \"files\", \"documents\", or wants to read/write organized content\n- **Key distinction**: These are **files** with paths like \"folder/note.md\"\n\n### 2. **Tags (标签)** - Organization Categories\n- **What**: Grouping labels to organize marks/records\n- **Storage**: SQLite database\n- **How to identify**: Tool names contain \"_tag\" (e.g., \"list_tags\", \"create_tag\")\n- **Purpose**: Categorize and organize marks; each tag can contain multiple marks\n- **Key distinction**: Tags are **categories**, NOT content themselves\n\n### 3. **Marks (记录)** - Content Records Under Tags\n- **What**: Individual content records stored under a specific tag\n- **Storage**: SQLite database (each mark belongs to one tag via tagId)\n- **How to identify**: Tool names contain \"_mark\" (e.g., \"read_marks\", \"create_mark\", \"search_marks\")\n- **Types**: scan, text, image, link, file, recording, todo\n- **Key distinction**: Marks are **content items** like bookmarks, captured text, OCR results, etc.\n\n### Decision Guide:\n| User Request | Concept | Tools to Use |\n|--------------|---------|--------------|\n| \"List my notes\" / \"Read note files\" | Note (file) | list_markdown_files, read_markdown_file |\n| \"Create a new note file\" | Note (file) | create_file |\n| \"Find/create tags\" | Tag | list_tags, create_tag |\n| \"List records in inbox\" / \"Create a bookmark\" | Mark | read_marks, create_mark |\n| \"Search my captures\" / \"Find saved content\" | Mark | search_marks |\n\n**IMPORTANT**: Never confuse these concepts! Tags organize Marks, but Tags and Marks are NOT the same as Notes (files).\n\n## Available Tools\n\n${toolDescriptions}`\n\n    // Add Skills instructions\n    if (skillsInstructions) {\n      prompt += `\n\n## Available Skills\n\n${skillsInstructions}`\n    }\n\n    prompt += `\n\n## Output Format Requirements\n\nYour every response **MUST strictly follow** one of these formats:\n\n### Format 1: Think and Execute Tool\n\\`\\`\\`\nThought: [Detailed thinking process explaining why to execute this operation]\nAction: tool_name\nAction Input: {\"param1\": \"value1\", \"param2\": \"value2\"}\n\\`\\`\\`\n\n**Example:**\n\\`\\`\\`\nThought: User wants to organize React notes, I need to search for all notes containing React keyword\nAction: search_notes\nAction Input: {\"query\": \"React\"}\n\\`\\`\\`\n\n### Format 2: Give Final Answer (IMPORTANT: Must use this format after task completion)\n\\`\\`\\`\nThought: I have completed all necessary operations, ready to give final answer\nFinal Answer: [Complete, user-friendly final answer]\n\\`\\`\\`\n\n**Example:**\n\\`\\`\\`\nThought: I have successfully created React knowledge summary note, task completed\nFinal Answer: Done! I created a note called \"React Knowledge Summary\" which includes organized content from 5 related notes.\n\\`\\`\\`\n\n## ⚠️ Important Rules (Must Follow)\n\n**🎯 Intent Judgment (CRITICAL)**:\n- If user is **asking a question** (What is...? How do I...? Tell me about...?) → Give Final Answer directly\n- If user is **requesting information** (Find..., Show me..., List...) → Use search/read tools, then answer\n- If user is **requesting an action** (Create..., Modify..., Delete..., Make...) → Use action tools\n- If **uncertain about intent** → Ask clarifying question in Final Answer format\n- **NEVER assume** user wants creation/modification when they're just asking or discussing\n\n**🔍 Search Tools Usage**:\n- Only use search_markdown_files when user explicitly asks to search (e.g., \"搜索\", \"查找\", \"帮我找\")\n- NEVER use search tools when user is just asking a question without requesting search\n- For RAG mode (semantic search): only use when user explicitly asks for \"语义搜索\" or \"AI搜索\"\n\n**📁 File Existence Claims**:\n- NEVER claim a file/folder \"does not exist\", \"was deleted\", or \"is missing\" unless a read/check tool observation explicitly confirms it\n- Do NOT infer missing files from conversation history or your own assumptions\n- If uncertain, first use a read-only check tool or ask the user for the exact file/path\n- If the user asks to summarize/analyze a note and the exact file is unclear, prefer asking a clarifying question over inventing a missing-file reason\n- If the target path ends with \\`.md\\`, treat it as a note file: use \\`read_markdown_file\\` or \\`read_markdown_files_batch\\`, not \\`check_folder_exists\\`\n- Only use \\`check_folder_exists\\` for actual folders, never for Markdown note paths\n- If context already includes the full content of the linked file, do not call read/check tools for that same file again. Answer directly from context.\n\n**Technical Rules**:\n1. **Strict Format**: Thought → Action + Action Input or Final Answer\n2. **JSON Format**: Action Input must be valid JSON with double quotes\n3. **One Tool at a Time**: Only call one tool per iteration\n4. **✅ TASK COMPLETION (CRITICAL)**: After a successful tool execution, decide whether the overall task is complete. If complete, give Final Answer immediately. If another required step remains, continue with that next step.\n5. **Don't Repeat**: Never repeat the same successful operation. Only continue when the previous observation clearly shows a different next step is still required.\n6. **Use Available Tools Only**: Don't make up tools or parameters\n7. **Concise Thinking**: Keep Thought brief, directly state what to do\n8. **🚨 Skills Are Not Tools**: NEVER use Action: skill_xxx, Skills are just guidance documents\n9. **📌 Quoted Content Rule**: If the user is asking to explain, summarize, analyze, translate, or discuss quoted content, answer directly and do NOT call editing tools. Only use replace_editor_content for quoted content when the user explicitly asks to modify, rewrite, insert, expand, or delete content.\n10. **📝 State-Based Reasoning**: Base your next action on the PREVIOUS observation result, not on the original user request - the context shows what you just did and the result\n\n## 🚫 Common Errors (Avoid)\n\n❌ **Error 1**: After modifying a note, continue searching or modifying the same note\n✅ **Correct**: After modifying note, directly give Final Answer\n\n❌ **Error 2**: After getting search results, search again with same conditions\n✅ **Correct**: After getting search results, execute operations based on results, then give Final Answer\n\n❌ **Error 3**: After creating a file, try to create another similar file (redundant creation)\n✅ **Correct**: After creating file, confirm success and immediately give Final Answer\n\n❌ **Error 4**: Try to call Skill as a tool (like Action: style-detector)\n✅ **Correct**: Understand Skill guidance, use actual tools (like Action: create_file) and follow Skill requirements in content\n\n❌ **Error 5**: Treat any quoted content as an edit request and call replace_editor_content for explanation/analysis tasks\n✅ **Correct**: For explanation/summary/analysis requests, answer directly from the quoted content. For explicit edit requests, if quoted context provides \\`from\\` and \\`to\\`, use them directly with replace_editor_content. Only fall back to startLine/endLine when exact positions are unavailable\n\n❌ **Error 6**: Ignore the previous operation result and repeat the same action\n✅ **Correct**: Always base your next action on the PREVIOUS observation result - if the result shows the task is complete, give Final Answer; if it shows a different required next step, continue with that next step\n\n❌ **Error 7**: Reconsider the original user request in every iteration instead of building on previous results\n✅ **Correct**: Focus on the PREVIOUS step's result - the context shows what you just did and what happened\n\n❌ **Error 8**: Use search tools when user is just asking a question without explicitly requesting search\n✅ **Correct**: Only use search_markdown_files when user explicitly says \"搜索\", \"查找\", \"帮我找\". For regular questions like \"What is React?\", give Final Answer directly without searching\n\n## Runtime Tool Policy\n\n${intentPolicyPrompt}\n\n## Example\n\n**Example 1: User asking a question (NO TOOL NEEDED)**\n\n**User**: \"What is React?\"\n\n**Iteration 1:**\n\\`\\`\\`\nThought: User is asking for information about React. This is a question, not a request to create content. I should answer directly.\nFinal Answer: React is a JavaScript library for building user interfaces, developed by Facebook. It uses a component-based architecture and virtual DOM for efficient rendering.\n\\`\\`\\`\n\n**Example 2: User requesting creation (USE TOOL)**\n\n**User**: \"Create a note introducing NoteGen\"\n\n**Iteration 1:**\n\\`\\`\\`\nThought: User explicitly requested to create a note. I will use the create_file tool.\nAction: create_file\nAction Input: {\"fileName\": \"NoteGen-Intro.md\", \"content\": \"# NoteGen\\\\n\\\\nAn intelligent note-taking software...\"}\n\\`\\`\\`\nObservation: File created successfully\n\n**Iteration 2:**\n\\`\\`\\`\nThought: Task completed\nFinal Answer: Created note \"NoteGen-Intro.md\"\n\\`\\`\\`\n\n**Example 3: User requesting information (USE SEARCH TOOL)**\n\n**User**: \"Find notes about React hooks\"\n\n**Iteration 1:**\n\\`\\`\\`\nThought: User wants to find information about React hooks from existing notes. I should search for relevant notes.\nAction: search_markdown_files\nAction Input: {\"query\": \"React hooks\"}\n\\`\\`\\`\nObservation: Found 3 notes about React hooks...\n\n**Iteration 2:**\n\\`\\`\\`\nThought: I found relevant information. Now I can answer the user's question.\nFinal Answer: I found 3 notes about React hooks: [summary of findings]\n\\`\\`\\`\n\nNow start executing the task!`\n\n    return prompt\n  }\n\n  private async think(\n    userInput: string,\n    context: string | undefined,\n    messages: OpenAI.Chat.ChatCompletionMessageParam[] | undefined,\n    systemPrompt: string,\n    imageUrls?: string[]\n  ): Promise<string> {\n    const historyContext = this.steps.map((step, i) =>\n      `Iteration ${i + 1}:\nThought: ${step.thought}\nAction: ${step.action?.tool}\nAction Input: ${JSON.stringify(step.action?.params)}\nObservation: ${step.observation}\n`\n    ).join('\\n')\n\n    // If messages array is provided, use it; otherwise use old string concatenation\n    if (messages && messages.length > 0) {\n      // Use messages array mode - build messages and add user request\n      const messagesForAI: OpenAI.Chat.ChatCompletionMessageParam[] = []\n\n      // Add system prompt (if any)\n      if (systemPrompt) {\n        messagesForAI.push({\n          role: 'system',\n          content: systemPrompt\n        })\n      }\n\n      // Add conversation history\n      messagesForAI.push(...messages)\n\n      // Add current iteration context (ReAct step history)\n      if (historyContext) {\n        messagesForAI.push({\n          role: 'system',\n          content: `## Previous Iterations\\n${historyContext}`\n        })\n      }\n\n      // 【关键修改】按照 LangChain 最佳实践：\n      // 第一次迭代：发送原始用户请求\n      // 后续迭代：只发送上一步操作的结果，不再重复发送原始请求\n      if (this.currentIteration === 1) {\n        messagesForAI.push({\n          role: 'user',\n          content: buildIterationUserMessage(this.currentIteration, userInput)\n        })\n      } else {\n        // 后续迭代：只发送上一步的结果\n        const lastStep = this.steps[this.steps.length - 1]\n        const lastObservation = lastStep?.observation || 'No previous result'\n        messagesForAI.push({\n          role: 'user',\n          content: buildIterationUserMessage(this.currentIteration, userInput, lastObservation)\n        })\n      }\n\n      // 调用实际的 LLM API\n      try {\n        const { fetchAiStream } = await import('@/lib/ai')\n        let response = ''\n        let lastUpdateLength = 0\n\n        // 传递 AbortSignal 以支持终止，同时传递图片URL（仅在第一次迭代时）\n        const imagesForThisIteration = this.currentIteration === 1 ? imageUrls : undefined\n        await fetchAiStream('', (content) => {\n          // 检查是否已终止\n          if (this.stopped) {\n            return\n          }\n\n          response = content\n\n          // 检测是否包含 Final Answer，提取内容并渲染 Markdown\n          const extractedFinalAnswer = this.extractFinalAnswer(content)\n          if (extractedFinalAnswer) {\n            // 包含 Final Answer，立即渲染 Markdown\n            this.config.onFinalAnswerRender?.(extractedFinalAnswer)\n          }\n\n          // 实时更新，但只在内容有实质性增长时更新（避免频繁更新）\n          if (content.length - lastUpdateLength > 10 || content.includes('Action:') || content.includes('Final Answer:')) {\n            this.config.onThought?.(content)\n            lastUpdateLength = content.length\n          }\n        }, this.abortController?.signal, undefined, undefined, undefined, imagesForThisIteration, undefined, messagesForAI)\n\n        // 检查是否已终止\n        if (this.stopped) {\n          return `Thought: User terminated the task\nFinal Answer: Task was terminated by user`\n        }\n\n        // 确保最终内容被更新\n        if (response.length !== lastUpdateLength) {\n          this.config.onThought?.(response)\n        }\n\n        // 第一次迭代后，不再根据文本提及自动选择 Skills。\n        // 只有显式调用 select_skill 工具才会生效，避免误命中无关 Skill。\n        if (this.currentIteration === 1) {\n          this.config.onSkillsSelected?.([])\n        }\n\n        return response\n      } catch (error) {\n        // 检查是否是因为终止导致的错误\n        if (this.stopped || (error instanceof Error && error.name === 'AbortError')) {\n          return `Thought: User terminated the task\nFinal Answer: Task was terminated by user`\n        }\n\n        console.error('LLM API call failed:', error)\n        // 如果 API 调用失败，返回错误提示\n        return `Thought: Sorry, AI service is temporarily unavailable\nFinal Answer: Unable to complete task, please retry later or check AI configuration`\n      }\n    }\n\n    // 旧的字符串拼接模式（向后兼容）\n    // 【关键修改】按照 LangChain 最佳实践：\n    // 第一次迭代：发送完整请求\n    // 后续迭代：只发送上一步结果，不再重复发送原始请求\n    let prompt: string\n    if (this.currentIteration === 1) {\n      prompt = `${systemPrompt}\n\n${context ? `## 上下文信息\\n${context}\\n` : ''}\n\n## 对话历史\n${historyContext}\n\n${buildIterationUserMessage(this.currentIteration, userInput)}`\n    } else {\n      // 后续迭代：只发送上一步的结果\n      const lastStep = this.steps[this.steps.length - 1]\n      const lastObservation = lastStep?.observation || '无'\n      prompt = `${systemPrompt}\n\n## 已完成的步骤\n${historyContext}\n\n${buildIterationUserMessage(this.currentIteration, userInput, lastObservation)}`\n    }\n\n    // 调用实际的 LLM API\n    try {\n      const { fetchAiStream } = await import('@/lib/ai')\n      let response = ''\n      let lastUpdateLength = 0\n\n      // 传递 AbortSignal 以支持终止，同时传递图片URL（仅在第一次迭代时）\n      const imagesForThisIteration = this.currentIteration === 1 ? imageUrls : undefined\n      await fetchAiStream(prompt, (content) => {\n        // 检查是否已终止\n        if (this.stopped) {\n          return\n        }\n\n        response = content\n\n        // 检测是否包含 Final Answer，提取内容并渲染 Markdown\n        const extractedFinalAnswer = this.extractFinalAnswer(content)\n        if (extractedFinalAnswer) {\n          // 包含 Final Answer，立即渲染 Markdown\n          this.config.onFinalAnswerRender?.(extractedFinalAnswer)\n        }\n\n        // 实时更新，但只在内容有实质性增长时更新（避免频繁更新）\n        if (content.length - lastUpdateLength > 10 || content.includes('Action:') || content.includes('Final Answer:')) {\n          this.config.onThought?.(content)\n          lastUpdateLength = content.length\n        }\n      }, this.abortController?.signal, undefined, undefined, undefined, imagesForThisIteration)\n      \n      // 检查是否已终止\n      if (this.stopped) {\n        return `Thought: 用户终止了任务\nFinal Answer: 任务已被用户终止`\n      }\n      \n      // 确保最终内容被更新\n      if (response.length !== lastUpdateLength) {\n        this.config.onThought?.(response)\n      }\n\n      // 第一次迭代后，不再根据文本提及自动选择 Skills。\n      // 只有显式调用 select_skill 工具才会生效，避免误命中无关 Skill。\n      if (this.currentIteration === 1) {\n        this.config.onSkillsSelected?.([])\n      }\n\n      return response\n    } catch (error) {\n      // 检查是否是因为终止导致的错误\n      if (this.stopped || (error instanceof Error && error.name === 'AbortError')) {\n        return `Thought: 用户终止了任务\nFinal Answer: 任务已被用户终止`\n      }\n      \n      console.error('LLM API call failed:', error)\n      // 如果 API 调用失败，返回错误提示\n      return `Thought: 抱歉，AI 服务暂时不可用\nFinal Answer: 无法完成任务，请稍后重试或检查 AI 配置`\n    }\n  }\n\n  private parseAction(thought: string): { tool: string; params: Record<string, any> } | null {\n    try {\n      // 首先检查是否包含 Final Answer - 如果是，返回 null\n      // 需要处理换行的情况，如 \"Action: Final\\nAnswer: ...\"\n      const normalizedThought = thought.replace(/\\s+/g, ' ')\n      if (normalizedThought.includes('Final Answer:') ||\n          normalizedThought.includes('Final Answer：') ||\n          normalizedThought.includes('最终答案') ||\n          // 处理 \"Action: Final\\nAnswer:\" 的情况\n          /Action:\\s*Final\\s*Answer/i.test(thought)) {\n        return null\n      }\n\n      // 修改正则表达式，支持工具名称中的连字符、下划线等字符\n      const actionMatch = thought.match(/Action:\\s*([a-zA-Z0-9_-]+)/i)\n\n      if (!actionMatch) {\n        return null\n      }\n\n      const tool = actionMatch[1]\n      let params = {}\n      \n      // 使用更宽松的正则匹配，获取 Action Input 后的所有内容\n      const inputMatch = thought.match(/Action Input:\\s*({[\\s\\S]*)/i)\n      \n      if (inputMatch) {\n        let jsonStr = inputMatch[1].trim()\n        \n        // 移除可能的标记符号（如 <|begin_of_box|> 和 <|end_of_box|>）\n        jsonStr = jsonStr.replace(/<\\|begin_of_box\\|>/g, '').replace(/<\\|end_of_box\\|>/g, '').trim()\n        \n        // 尝试找到完整的 JSON 对象\n        let braceCount = 0\n        let jsonEnd = -1\n        let inString = false\n        let escapeNext = false\n        \n        for (let i = 0; i < jsonStr.length; i++) {\n          const char = jsonStr[i]\n          \n          if (escapeNext) {\n            escapeNext = false\n            continue\n          }\n          \n          if (char === '\\\\') {\n            escapeNext = true\n            continue\n          }\n          \n          if (char === '\"' && !escapeNext) {\n            inString = !inString\n            continue\n          }\n          \n          if (!inString) {\n            if (char === '{') {\n              braceCount++\n            } else if (char === '}') {\n              braceCount--\n              if (braceCount === 0) {\n                jsonEnd = i + 1\n                break\n              }\n            }\n          }\n        }\n        \n        // 如果找到了完整的 JSON，截取它\n        if (jsonEnd > 0) {\n          jsonStr = jsonStr.substring(0, jsonEnd)\n        }\n        \n        const parsed = parseActionInputJson(jsonStr)\n        if (!parsed) {\n          // 返回 null 而不是空对象，让调用方知道解析失败\n          return null\n        }\n\n        params = parsed\n      }\n\n      return { tool, params }\n    } catch (error) {\n      console.error('Failed to parse action:', error)\n      return null\n    }\n  }\n\n  private async act(toolName: string, params: Record<string, any>, thought?: string): Promise<string> {\n    const tool = getToolByName(toolName)\n\n    if (!tool) {\n      return `错误：未找到工具 \"${toolName}\"。请使用可用的工具列表中的工具。`\n    }\n\n    params = this.normalizeToolParams(toolName, params)\n\n    this.toolCallCounter++\n    const toolCall: ToolCall = {\n      id: `${Date.now()}-${this.toolCallCounter}-${Math.random().toString(36).substring(2, 11)}`,\n      toolName,\n      params,\n      status: 'pending',\n      timestamp: Date.now(),\n    }\n\n    const policyCheck = this.evaluateToolPolicy(toolName, tool, params)\n    if (!policyCheck.allowed) {\n      const blockedMessage = this.getPolicyAdjustmentMessage(toolName, policyCheck.reason || '已调整工具选择')\n      const isBenignAdjustment = Boolean(policyCheck.reason?.includes('完整内容已在上下文中'))\n      toolCall.status = isBenignAdjustment ? 'success' : 'error'\n      toolCall.result = {\n        success: isBenignAdjustment,\n        error: isBenignAdjustment ? undefined : `BLOCKED_BY_POLICY: ${policyCheck.reason}`,\n        message: blockedMessage,\n      }\n      this.config.onToolCall?.(toolCall)\n      return blockedMessage\n    }\n\n    // 查找哪个 Skill 授权了这个工具\n    const authorizingSkills: string[] = []\n    if (this.config.activeSkills && this.config.activeSkills.length > 0) {\n      for (const skillId of this.config.activeSkills) {\n        const skill = skillManager.getSkill(skillId)\n        // 移除 enabled 判断，只要 Skill 存在就检查授权\n        if (skill && skill.metadata.allowedTools?.includes(toolName)) {\n          authorizingSkills.push(skill.metadata.name)\n        }\n      }\n    }\n\n    this.config.onToolCall?.(toolCall)\n\n    // 检查工具是否在当前激活的 Skills 中被授权\n    const isAuthorized = this.isToolAuthorized(toolName)\n    const requiresConfirmation = policyCheck.requiresConfirmation || (tool.requiresConfirmation && !isAuthorized)\n\n    if (requiresConfirmation && !this.config.requestConfirmation) {\n      toolCall.status = 'error'\n      toolCall.result = {\n        success: false,\n        error: 'BLOCKED_BY_POLICY: 操作需要确认，但未配置确认回调',\n      }\n      this.config.onToolCall?.(toolCall)\n      return '这个操作需要你的确认，当前先不执行。'\n    }\n\n    if (requiresConfirmation && this.config.requestConfirmation) {\n      // 准备确认上下文信息（原始内容、修改后内容、文件路径）\n      const confirmContext: {\n        originalContent?: string\n        modifiedContent?: string\n        filePath?: string\n      } = {}\n\n      // 对于 modify_current_note 工具，获取原始内容和修改后的内容用于 diff 显示\n      if (toolName === 'modify_current_note') {\n        try {\n          const { getFilePathOptions } = await import('@/lib/workspace')\n          const { readTextFile } = await import('@tauri-apps/plugin-fs')\n          const useArticleStore = (await import('@/stores/article')).default\n\n          const articleStore = useArticleStore.getState()\n          const currentFilePath = articleStore.activeFilePath\n\n          if (currentFilePath) {\n            confirmContext.filePath = currentFilePath\n\n            // 读取原始内容\n            const { path, baseDir } = await getFilePathOptions(currentFilePath)\n            let originalContent = ''\n            if (baseDir) {\n              originalContent = await readTextFile(path, { baseDir })\n            } else {\n              originalContent = await readTextFile(path)\n            }\n\n            // 导入工具函数来计算修改后的内容\n            const { searchReplaceContent, insertLinesAtPosition, deleteLinesInRange, replaceLinesInRange } = await import('./react-diff-helpers')\n\n            // 计算修改后的内容（用于 diff 显示）\n            let modifiedContent = originalContent\n\n            if (params.searchReplace) {\n              const sr = params.searchReplace\n              modifiedContent = searchReplaceContent(\n                modifiedContent,\n                sr.searchPattern || '',\n                sr.replacement || '',\n                sr.useRegex || false,\n                sr.caseSensitive || false,\n                sr.replaceAll !== false\n              )\n            } else if (params.insertLines) {\n              const il = params.insertLines\n              const newLines = Array.isArray(il.newLines) ? il.newLines : [il.newLines]\n              modifiedContent = insertLinesAtPosition(\n                modifiedContent,\n                il.afterLine || 0,\n                newLines\n              )\n            } else if (params.deleteLines) {\n              const dl = params.deleteLines\n              modifiedContent = deleteLinesInRange(\n                modifiedContent,\n                dl.startLine,\n                dl.endLine\n              )\n            } else if (params.lineEdits && Array.isArray(params.lineEdits)) {\n              // 处理 lineEdits\n              const sortedEdits = [...params.lineEdits].sort((a, b) => b.startLine - a.startLine)\n              for (const edit of sortedEdits) {\n                modifiedContent = replaceLinesInRange(\n                  modifiedContent,\n                  edit.startLine,\n                  edit.endLine,\n                  edit.newLines\n                )\n              }\n            } else if (params.content) {\n              modifiedContent = params.content\n            }\n\n            // 提取变化的区域（只显示有变化的行及其上下文）\n            const extractChangedRegion = (original: string, modified: string, contextLines = 3) => {\n              const originalLines = original.split('\\n')\n              const modifiedLines = modified.split('\\n')\n\n              // 找到第一个和最后一个不同的行\n              let firstDiff = -1\n              let lastDiff = -1\n\n              const maxLines = Math.max(originalLines.length, modifiedLines.length)\n              for (let i = 0; i < maxLines; i++) {\n                if (originalLines[i] !== modifiedLines[i]) {\n                  if (firstDiff === -1) firstDiff = i\n                  lastDiff = i\n                }\n              }\n\n              // 如果没有变化，返回前 50 行\n              if (firstDiff === -1) {\n                const previewLines = 50\n                return {\n                  original: originalLines.slice(0, previewLines).join('\\n'),\n                  modified: modifiedLines.slice(0, previewLines).join('\\n')\n                }\n              }\n\n              // 提取变化区域及其上下文\n              const start = Math.max(0, firstDiff - contextLines)\n              const end = Math.min(maxLines, lastDiff + contextLines + 1)\n\n              return {\n                original: originalLines.slice(start, end).join('\\n'),\n                modified: modifiedLines.slice(start, end).join('\\n'),\n                hasMore: end < maxLines\n              }\n            }\n\n            const changedRegion = extractChangedRegion(originalContent, modifiedContent)\n            confirmContext.originalContent = changedRegion.original\n            confirmContext.modifiedContent = changedRegion.modified\n\n          }\n        } catch (error) {\n          console.error('[Agent] Failed to prepare diff context:', error)\n        }\n      }\n\n      const confirmed = await this.config.requestConfirmation(toolName, params, confirmContext)\n\n      if (!confirmed) {\n        toolCall.status = 'error'\n        toolCall.result = {\n          success: false,\n          error: '用户取消了操作',\n        }\n        this.config.onToolCall?.(toolCall)\n        return '用户取消了操作'\n      }\n    }\n\n    toolCall.status = 'running'\n    this.config.onToolCall?.(toolCall)\n\n    try {\n      const result: ToolResult = await tool.execute(params)\n\n      toolCall.status = result.success ? 'success' : 'error'\n      toolCall.result = result\n      this.config.onToolCall?.(toolCall)\n\n      if (result.success) {\n        // 特殊处理 select_skill 工具\n        if (toolName === 'select_skill' && result.data?.selected_skills) {\n          const selectedSkillIds: string[] = result.data.selected_skills\n\n          // 更新 selectedSkills\n          for (const skillId of selectedSkillIds) {\n            this.selectedSkills.add(skillId)\n          }\n\n          // 通知外部选择的 Skills\n          this.config.onSkillsSelected?.(selectedSkillIds)\n        }\n\n        let observation = result.message || `工具 ${toolName} 执行成功。`\n\n        // 如果有数据，根据数据类型进行格式化\n        if (result.data) {\n          // 特殊处理 MCP 搜索结果（category 为 'mcp' 的工具）\n          if (tool.category === 'mcp') {\n            // 从思考内容中提取简短标题\n            const shortTitle = thought ? this.extractTitleFromThought(thought) : tool.description\n            observation = this.formatMcpResult(shortTitle, result.data)\n          } else if (Array.isArray(result.data)) {\n            if (result.data.length > 0) {\n              observation += `\\n\\n数据详情：\\n${JSON.stringify(result.data, null, 2)}`\n            }\n          } else {\n            // 对于对象数据，也格式化显示\n            observation += `\\n\\n数据详情：\\n${JSON.stringify(result.data, null, 2)}`\n          }\n        }\n\n        return observation\n      } else {\n        const errorMsg = result.error || '未知错误'\n        return `工具 ${toolName} 执行失败：${errorMsg}`\n      }\n    } catch (error) {\n      toolCall.status = 'error'\n      const errorStr = error instanceof Error ? error.message : String(error)\n      toolCall.result = {\n        success: false,\n        error: errorStr,\n      }\n      this.config.onToolCall?.(toolCall)\n      return `工具 ${toolName} 执行出错：${errorStr}`\n    }\n  }\n\n  private normalizeToolParams(toolName: string, params: Record<string, any>): Record<string, any> {\n    if (toolName === 'create_file') {\n      return this.normalizeCreateFileParams(params)\n    }\n\n    if (toolName !== 'replace_editor_content') {\n      return params\n    }\n\n    const currentQuote = this.config.currentQuote\n    if (!currentQuote) {\n      return params\n    }\n\n    if (currentQuote.from < 0 || currentQuote.to < currentQuote.from) {\n      return params\n    }\n\n    const normalizedParams = { ...params }\n    const insertDirective = this.getQuotedInsertDirective()\n    const rawContent = typeof normalizedParams.content === 'string'\n      ? normalizedParams.content\n      : typeof normalizedParams.replaceContent === 'string'\n        ? normalizedParams.replaceContent\n        : ''\n\n    if (insertDirective && rawContent.trim().length > 0) {\n      delete normalizedParams.startLine\n      delete normalizedParams.endLine\n      delete normalizedParams.searchContent\n      delete normalizedParams.occurrence\n      delete normalizedParams.replaceContent\n\n      normalizedParams.from = currentQuote.from\n      normalizedParams.to = currentQuote.to\n      normalizedParams.content = this.buildQuotedInsertContent(\n        insertDirective,\n        rawContent,\n        currentQuote.fullContent\n      )\n\n      return normalizedParams\n    }\n\n    delete normalizedParams.startLine\n    delete normalizedParams.endLine\n    delete normalizedParams.searchContent\n    delete normalizedParams.occurrence\n\n    normalizedParams.from = currentQuote.from\n    normalizedParams.to = currentQuote.to\n\n    if (normalizedParams.replaceContent !== undefined && normalizedParams.content === undefined) {\n      normalizedParams.content = normalizedParams.replaceContent\n    }\n\n    return normalizedParams\n  }\n\n  private normalizeCreateFileParams(params: Record<string, any>): Record<string, any> {\n    if (this.selectedSkills.size !== 1) {\n      return params\n    }\n\n    const rawFileName = typeof params.fileName === 'string' ? params.fileName.trim() : ''\n    if (!rawFileName) {\n      return params\n    }\n\n    const rawFolderPath = typeof params.folderPath === 'string' ? params.folderPath.trim() : ''\n    const scriptPattern = /\\.(?:js|mjs|cjs|ts|py|sh|bash)$/i\n    const selectedSkillId = Array.from(this.selectedSkills)[0]\n    const runtimeFolder = `skills/${selectedSkillId}/runtime`\n    const runtimePrefix = `${runtimeFolder}/`\n\n    const fileNameLooksLikeScript = scriptPattern.test(rawFileName)\n    const folderLooksLikeScriptTarget = scriptPattern.test(rawFolderPath)\n    if (!fileNameLooksLikeScript && !folderLooksLikeScriptTarget) {\n      return params\n    }\n\n    const normalizedParams = { ...params }\n\n    if (rawFileName.startsWith(runtimePrefix)) {\n      normalizedParams.fileName = rawFileName.slice(runtimePrefix.length)\n      normalizedParams.folderPath = runtimeFolder\n    } else if (rawFileName.includes('/')) {\n      const segments = rawFileName.split('/').filter(Boolean)\n      const extractedFileName = segments.pop()\n      if (extractedFileName) {\n        normalizedParams.fileName = extractedFileName\n        normalizedParams.folderPath = segments.join('/')\n      }\n    }\n\n    const currentFolderPath = typeof normalizedParams.folderPath === 'string'\n      ? normalizedParams.folderPath.trim()\n      : ''\n\n    if (!currentFolderPath) {\n      normalizedParams.folderPath = runtimeFolder\n    } else if (currentFolderPath === `skills/${selectedSkillId}`) {\n      normalizedParams.folderPath = runtimeFolder\n    } else if (currentFolderPath === 'runtime') {\n      normalizedParams.folderPath = runtimeFolder\n    } else if (currentFolderPath.startsWith('runtime/')) {\n      normalizedParams.folderPath = `${runtimeFolder}/${currentFolderPath.slice('runtime/'.length)}`\n    }\n\n    return normalizedParams\n  }\n\n  private getQuotedInsertDirective(): 'before' | 'after' | 'around' | null {\n    if (!/插入|添加|补充|加入|增加/.test(this.currentUserInput)) {\n      return null\n    }\n\n    const hasBefore = /前面|前边|上面|之前|前方/.test(this.currentUserInput)\n    const hasAfter = /后面|后边|下面|之后|后方/.test(this.currentUserInput)\n\n    if (hasBefore && hasAfter) {\n      return 'around'\n    }\n\n    if (hasBefore) {\n      return 'before'\n    }\n\n    if (hasAfter) {\n      return 'after'\n    }\n\n    return null\n  }\n\n  private buildQuotedInsertContent(\n    directive: 'before' | 'after' | 'around',\n    insertedContent: string,\n    quoteContent?: string\n  ): string {\n    const normalizedInserted = insertedContent.trim()\n    const normalizedQuote = quoteContent?.trim()\n\n    if (!normalizedQuote) {\n      return normalizedInserted\n    }\n\n    if (normalizedInserted.includes(normalizedQuote)) {\n      return normalizedInserted\n    }\n\n    if (directive === 'before') {\n      return `${normalizedInserted}\\n${normalizedQuote}`\n    }\n\n    if (directive === 'around') {\n      const structuredAround = normalizedInserted.match(\n        /^<<BEFORE>>\\s*([\\s\\S]*?)\\s*<<AFTER>>\\s*([\\s\\S]*)$/i\n      )\n\n      if (structuredAround) {\n        const beforeContent = structuredAround[1].trim()\n        const afterContent = structuredAround[2].trim()\n\n        return [\n          beforeContent,\n          normalizedQuote,\n          afterContent,\n        ].filter(Boolean).join('\\n\\n')\n      }\n\n      // Fallback: preserve the quoted content and append the generated content once.\n      return `${normalizedQuote}\\n\\n${normalizedInserted}`\n    }\n\n    return `${normalizedQuote}\\n${normalizedInserted}`\n  }\n\n  /**\n   * 从思考内容中提取简短标题\n   */\n  private extractTitleFromThought(thought: string): string {\n    // 移除 \"Thought:\" 前缀\n    const content = thought.replace(/^Thought:\\s*/i, '').trim()\n\n    // 提取第一句话或前50个字符\n    const firstSentence = content.split(/[。！？.!?]/)[0]\n    if (firstSentence && firstSentence.length > 0 && firstSentence.length < 100) {\n      return firstSentence.trim()\n    }\n\n    // 如果第一句话太长或没有句子结束符，截取前50个字符\n    if (content.length > 50) {\n      return content.substring(0, 50) + '...'\n    }\n\n    return content\n  }\n\n  /**\n   * 格式化 MCP 工具的返回结果\n   */\n  private formatMcpResult(toolDescription: string, data: any): string {\n    // 处理搜索结果\n    if (data.results && Array.isArray(data.results)) {\n      const results = data.results\n      let formatted = `MCP: ${toolDescription}，找到 ${results.length} 条结果：\\n\\n`\n\n      results.forEach((item: any, index: number) => {\n        formatted += `${index + 1}. ${item.title || '无标题'}\\n`\n        formatted += `   ${item.snippet || item.description || '无描述'}\\n`\n        formatted += `   UUID: ${item.uuid}\\n`\n        if (item.url) {\n          formatted += `   URL: ${item.url}\\n`\n        }\n        formatted += '\\n'\n      })\n\n      return formatted\n    }\n\n    // 处理网页抓取结果\n    if (data.content && typeof data.content === 'string') {\n      return `MCP: ${toolDescription}：\\n\\n${data.content}`\n    }\n\n    // 其他情况使用 JSON 格式化\n    return `MCP: ${toolDescription}\\n\\n返回结果：\\n${JSON.stringify(data, null, 2)}`\n  }\n\n  getSteps(): ReActStep[] {\n    return this.steps\n  }\n\n  getCurrentIteration(): number {\n    return this.currentIteration\n  }\n\n  /**\n   * 格式化 Skills 指令为系统提示\n   * 只发送元数据和简要说明，完整指令由 AI 根据描述理解并执行\n   */\n  private formatSkillsInstructions(): string {\n    const activeSkillIds = this.config.activeSkills\n    if (!activeSkillIds || activeSkillIds.length === 0) {\n      return ''\n    }\n\n    // First iteration: only send brief info (name and description), let AI choose\n    if (this.currentIteration === 1) {\n      const skillsList: string[] = []\n      const skillsDebugInfo: any[] = []\n\n      for (const skillId of activeSkillIds) {\n        const skill = skillManager.getSkill(skillId)\n        if (!skill) {\n          continue\n        }\n\n        // Only send brief information\n        let skillText = `### ${skill.metadata.name}\\n\\n`\n        skillText += `- Description: ${skill.metadata.description}\\n`\n        skillText += `- ID: ${skill.metadata.id}\\n\\n`\n\n        skillsList.push(skillText)\n        skillsDebugInfo.push({\n          id: skill.metadata.id,\n          name: skill.metadata.name,\n          description: skill.metadata.description\n        })\n      }\n\n      if (skillsList.length === 0) {\n        return ''\n      }\n\n      const result = `## Available Skills\n\n**Step 1: Use select_skill tool to choose appropriate Skill**\n\nPlease select the most relevant skill(s) from the following based on user task:\n\n${skillsList.join('\\n---\\n\\n')}\n\n**🚨 You MUST use tool to select Skill!**\n\nCorrect way to select Skill:\n\\`\\`\\`\nThought: User wants to write web fiction, I need to select style-detector Skill to guide writing style.\nAction: select_skill\nAction Input: {\"skill_ids\": [\"style-detector\"]}\n\\`\\`\\`\n\nAfter selecting Skill, you will receive complete Skill instructions in next iteration. Then you can use actual tools (like create_file) to complete the task.\n\n**Important Notes**:\n- Carefully read each Skill's description\n- Use \\`select_skill\\` tool to select Skill\n- Pass Skill ID array in Action Input (e.g.: [\"style-detector\", \"weekly\"])\n- After selection, wait for next iteration, complete Skill instructions will be provided\n- NEVER use Skill name directly as Action`\n\n      return result\n    }\n\n    // Subsequent iterations: only send complete content of selected Skills\n    if (this.selectedSkills.size === 0) {\n      return ''\n    }\n\n    const skillsList: string[] = []\n    const skillsDebugInfo: any[] = []\n\n    for (const skillId of this.selectedSkills) {\n      const skill = skillManager.getSkill(skillId)\n      if (!skill) {\n        continue\n      }\n\n      // Send complete Skill information\n      let skillText = `### ${skill.metadata.name}\\n\\n`\n\n      // YAML metadata section\n      skillText += `**Metadata**:\\n`\n      skillText += `- Description: ${skill.metadata.description}\\n`\n      skillText += `- Version: ${skill.metadata.version}\\n`\n      if (skill.metadata.author) {\n        skillText += `- Author: ${skill.metadata.author}\\n`\n      }\n      if (skill.metadata.allowedTools && skill.metadata.allowedTools.length > 0) {\n        skillText += `- Authorized Tools: ${skill.metadata.allowedTools.join(', ')}\\n`\n      }\n      skillText += `\\n`\n\n      // 添加可用脚本列表\n      if (skill.scripts && skill.scripts.length > 0) {\n        skillText += `**Available Scripts**:\\n`\n        for (const script of skill.scripts) {\n          skillText += `  - \\`${script.name}\\` (${script.type})\\n`\n        }\n        skillText += `\\n`\n      }\n\n      // Complete instructions section (Markdown content)\n      skillText += `**Instructions**:\\n${skill.instructions}\\n\\n`\n\n      skillsList.push(skillText)\n\n      // Collect debug info\n      skillsDebugInfo.push({\n        id: skill.metadata.id,\n        name: skill.metadata.name,\n        description: skill.metadata.description,\n        instructionLength: skill.instructions.length\n      })\n    }\n\n    if (skillsList.length === 0) {\n      return ''\n    }\n\n    const result = `## Selected Skills\n\nYou selected the following Skills to guide current task:\n\n${skillsList.join('\\n---\\n\\n')}\n\n**📋 How to use these Skills**:\n\n1. **Carefully read complete instructions of above Skills**\n2. **Understand Skill requirements, then apply directly to your work**\n3. **Don't ask user for confirmation** - Execute tasks directly following Skill guidance\n4. **Don't try to read additional files** - Skills already contain all necessary information\n5. **Use actual tools to complete tasks** - Like create_file, modify_current_note, etc.\n\n**⚠️ Important Reminders**:\n- Strictly follow above Skill requirements to execute tasks\n- Don't try to call Skill as a tool\n- Don't ask user for style selection - directly apply most relevant style\n- If it's style-detector Skill, directly apply corresponding style (like web fiction style) to your content`\n\n    return result\n  }\n\n  /**\n   * 从思考内容中提取提到的 Skills\n   */\n  private extractMentionedSkills(thought: string): string[] {\n    const mentioned: string[] = []\n    if (!this.config.activeSkills || this.config.activeSkills.length === 0) {\n      return mentioned\n    }\n\n    for (const skillId of this.config.activeSkills) {\n      const skill = skillManager.getSkill(skillId)\n      if (skill) {\n        // 检查是否提到了 Skill 的名称或描述中的关键词\n        const skillName = skill.metadata.name.toLowerCase()\n        const keywords = [\n          skillName,\n          ...skill.metadata.name.split(/\\s+/),\n          ...skill.metadata.description.toLowerCase().split(/\\s+/).filter(w => w.length > 3)\n        ]\n\n        const thoughtLower = thought.toLowerCase()\n        if (keywords.some(keyword => thoughtLower.includes(keyword))) {\n          mentioned.push(skill.metadata.name)\n        }\n      }\n    }\n\n    return mentioned\n  }\n\n  /**\n   * 从内容中提取 Final Answer（用于流式渲染 Markdown）\n   */\n  private extractFinalAnswer(content: string): string | null {\n    // 检测是否包含 Final Answer\n    const normalizedContent = content.replace(/\\s+/g, ' ')\n    const hasFinalAnswer = normalizedContent.includes('Final Answer:') ||\n                           normalizedContent.includes('Final Answer：') ||\n                           normalizedContent.includes('最终答案') ||\n                           /Action:\\s*Final\\s*Answer/i.test(content)\n\n    if (!hasFinalAnswer) {\n      return null\n    }\n\n    // 提取 Final Answer 后面的内容\n    let result: string | null = null\n    if (content.includes('Final Answer:')) {\n      result = content.split('Final Answer:')[1].trim()\n    } else if (content.includes('Final Answer：')) {\n      result = content.split('Final Answer：')[1].trim()\n    } else if (content.includes('最终答案')) {\n      result = content.split('最终答案')[1].trim()\n    } else if (/Action:\\s*Final\\s*Answer:\\s*([\\s\\S]*)/i.test(content)) {\n      const match = content.match(/Action:\\s*Final\\s*Answer:\\s*([\\s\\S]*)/i)\n      if (match) {\n        result = match[1].trim()\n      }\n    }\n\n    return result\n  }\n\n  /**\n   * 检查工具是否在当前激活的 Skills 中被授权（移除 enabled 判断）\n   */\n  isToolAuthorized(toolName: string): boolean {\n    if (this.selectedSkills.size === 0) {\n      return false\n    }\n\n    for (const skillId of this.selectedSkills) {\n      const skill = skillManager.getSkill(skillId)\n      // 移除 enabled 判断，只要 Skill 存在且授权了工具就返回 true\n      if (skill && skill.metadata.allowedTools?.includes(toolName)) {\n        return true\n      }\n    }\n\n    return false\n  }\n\n  private evaluateToolPolicy(\n    toolName: string,\n    tool: { category: string; requiresConfirmation: boolean },\n    params: Record<string, any> = {}\n  ): { allowed: boolean; requiresConfirmation: boolean; reason?: string } {\n    const folderPath = typeof params.folderPath === 'string' ? params.folderPath.trim() : ''\n    const { linkedResource } = useChatStore.getState()\n\n    if (toolName === 'check_folder_exists' && /\\.md$/i.test(folderPath)) {\n      return {\n        allowed: false,\n        requiresConfirmation: false,\n        reason: 'Markdown 文件路径应使用 read_markdown_file，而不是 check_folder_exists',\n      }\n    }\n\n    if (linkedResource && !isLinkedFolder(linkedResource) && shouldKeepFocusOnLinkedNote(this.currentUserInput, linkedResource, toolName)) {\n      return {\n        allowed: false,\n        requiresConfirmation: false,\n        reason: '当前任务应聚焦关联笔记文件内容，不应切换到标签或记录工具',\n      }\n    }\n\n    if (shouldBlockRepeatedNoteExploration(toolName, params, this.steps)) {\n      return {\n        allowed: false,\n        requiresConfirmation: false,\n        reason: '已经获得足够的笔记文件内容，无需重复列出或读取，请直接基于已有内容继续整理并给出最终答案',\n      }\n    }\n\n    if (this.isRedundantLinkedFileRead(toolName, params)) {\n      return {\n        allowed: false,\n        requiresConfirmation: false,\n        reason: '当前关联文件的完整内容已在上下文中，无需再次读取或检查',\n      }\n    }\n\n    return evaluateIntentAwareToolPolicy({\n      toolName,\n      category: tool.category,\n      intentPolicy: this.intentPolicy,\n    })\n  }\n\n  private getPolicyAdjustmentMessage(toolName: string, reason: string): string {\n    if (reason.includes('Markdown 文件路径')) {\n      return `已调整工具选择：Markdown 文件会按笔记文件读取，而不是按文件夹处理。不要再次调用 ${toolName}，请改用 read_markdown_file。`\n    }\n\n    if (reason.includes('完整内容已在上下文中')) {\n      return '已直接使用关联文件上下文：这篇笔记的完整内容已经在当前对话中，无需再次读取。'\n    }\n\n    if (reason.includes('聚焦关联笔记文件内容')) {\n      return '已保持任务聚焦：当前应先基于关联笔记文件继续分析或整理，不要切换到标签/记录工具。'\n    }\n\n    if (reason.includes('已经获得足够的笔记文件内容')) {\n      return '已避免重复探索：你已经拿到足够的笔记内容，请直接基于已读取内容继续整理，并给出 Final Answer。'\n    }\n\n    if (reason.includes('执行命令或脚本')) {\n      return '已保持分析模式：不会执行命令或脚本。'\n    }\n\n    if (reason.includes('删除或清空')) {\n      return '已避免高风险操作：当前不会删除或清空内容。'\n    }\n\n    if (reason.includes('默认只读模式') || reason.includes('修改意图')) {\n      return '已保持分析优先：先分析内容，需要修改时再确认。'\n    }\n\n    return '已调整工具选择，继续采用更合适的处理方式。'\n  }\n\n  private isPolicyAdjustmentObservation(observation?: string): boolean {\n    if (!observation) {\n      return false\n    }\n\n    return observation.includes('已调整工具选择：') ||\n      observation.includes('已保持任务聚焦：') ||\n      observation.includes('已避免重复探索：')\n  }\n\n  private isRedundantLinkedFileRead(toolName: string, params: Record<string, any>): boolean {\n    const { linkedResource } = useChatStore.getState()\n\n    if (!linkedResource || isLinkedFolder(linkedResource)) {\n      return false\n    }\n\n    return shouldBlockRedundantLinkedFileRead(toolName, params, linkedResource)\n  }\n\n  private isSupportOnlyTool(toolName?: string): boolean {\n    if (!toolName) {\n      return false\n    }\n\n    return toolName === 'select_skill' || toolName === 'load_skill_content'\n  }\n\n  private hasSubstantiveSuccessfulAction(): boolean {\n    return this.steps.some((step) => {\n      const toolName = step.action?.tool\n      if (!toolName || this.isSupportOnlyTool(toolName)) {\n        return false\n      }\n\n      const observation = step.observation || ''\n      if (!observation) {\n        return false\n      }\n\n      return !observation.includes('失败') && !observation.includes('错误') && !observation.includes('阻止')\n    })\n  }\n\n  private validateFinalAnswerReadiness(userInput: string, finalAnswer: string): { ok: boolean; reason?: string } {\n    const normalizedInput = userInput.toLowerCase()\n    const normalizedAnswer = finalAnswer.toLowerCase()\n    const actionLikeRequest = this.intentPolicy.allowWrite || this.intentPolicy.allowExecute || this.intentPolicy.allowDestructive\n    const hasOnlySupportSteps = this.steps.length > 0 && this.steps.every((step) => this.isSupportOnlyTool(step.action?.tool))\n    const claimsExecution = /已生成|已创建|已保存|已完成|已导出|已验证|成功使用|generated|created|saved|exported|verified|completed/.test(finalAnswer)\n    const requestedArtifact = /生成|创建|制作|导出|保存|输出|pptx|pdf|docx|xlsx|文件|演示文稿|generate|create|export|save|file|presentation/.test(normalizedInput)\n\n    if (actionLikeRequest && requestedArtifact && claimsExecution && !this.hasSubstantiveSuccessfulAction()) {\n      return {\n        ok: false,\n        reason: hasOnlySupportSteps\n          ? '仅完成了 Skill 选择或说明读取，尚未真正执行创建/脚本工具，不能宣称文件已生成。请继续执行实际工具。'\n          : '尚未获得真实工具成功结果，不能宣称文件已生成、已保存或已验证。请继续执行实际工具。',\n      }\n    }\n\n    if (this.selectedSkills.size > 0 && claimsExecution && !this.hasSubstantiveSuccessfulAction()) {\n      return {\n        ok: false,\n        reason: '已选择 Skill，但还没有真正完成执行步骤。请先完成 create_file、execute_skill_script 或其他实际工具调用，再给最终答案。',\n      }\n    }\n\n    if (normalizedAnswer.includes('验证通过') && !this.hasSubstantiveSuccessfulAction()) {\n      return {\n        ok: false,\n        reason: '还没有真实执行结果可供验证，不能声称“已验证通过”。请先执行实际工具。',\n      }\n    }\n\n    return { ok: true }\n  }\n}\n"
  },
  {
    "path": "src/lib/agent/session-approval.ts",
    "content": "import type { Tool } from './types'\n\nexport interface SessionApprovalScope {\n  type: 'write' | 'runtime-script-skill'\n  skillId?: string\n}\n\nfunction isRecoverableWriteToolLocally(toolName: string, tool: Tool | undefined): boolean {\n  if (!tool) {\n    return false\n  }\n\n  if (tool.category === 'editor') {\n    return !toolName.startsWith('delete_') && toolName !== 'execute_skill_script'\n  }\n\n  return [\n    'create_file',\n    'create_files_batch',\n    'create_mark',\n    'create_marks_batch',\n    'update_mark',\n    'update_marks_batch',\n    'create_tag',\n    'update_tag',\n    'create_chat',\n    'create_chats_batch',\n    'update_chat',\n    'update_chats_batch',\n    'insert_at_cursor',\n    'replace_editor_content',\n    'rename_file',\n    'move_file',\n    'copy_file',\n    'rename_files_batch',\n    'move_files_batch',\n    'copy_files_batch',\n  ].includes(toolName)\n}\n\nfunction classifySkillScriptPathLocally(arg: string): 'generated-runtime-script' | 'runtime-script' | 'builtin-skill-script' | 'other' {\n  const normalized = arg.replace(/\\\\/g, '/')\n\n  if (/^skills\\/[^/]+\\/scripts\\/[^/]+\\/[^/]+$/.test(normalized)) {\n    return 'generated-runtime-script'\n  }\n\n  if (normalized.startsWith('scripts/')) {\n    return 'builtin-skill-script'\n  }\n\n  if (!normalized.includes('/') && /\\.(py|js|mjs|cjs|sh|bash)$/i.test(normalized)) {\n    return 'runtime-script'\n  }\n\n  return 'other'\n}\n\nexport function getSessionApprovalScope(\n  toolName: string,\n  tool: Tool | undefined,\n  params: Record<string, any>\n): SessionApprovalScope | null {\n  if (isRecoverableWriteToolLocally(toolName, tool)) {\n    return { type: 'write' }\n  }\n\n  if (toolName !== 'execute_skill_script') {\n    return null\n  }\n\n  const skillId = typeof params.skill_id === 'string' ? params.skill_id.trim() : ''\n  const firstArg = Array.isArray(params.args) && typeof params.args[0] === 'string'\n    ? params.args[0]\n    : ''\n  if (!skillId || !firstArg) {\n    return null\n  }\n\n  const classified = classifySkillScriptPathLocally(firstArg)\n  if (classified === 'runtime-script' || classified === 'generated-runtime-script') {\n    return {\n      type: 'runtime-script-skill',\n      skillId,\n    }\n  }\n\n  return null\n}\n\nexport function matchesSessionApproval(\n  approvedConversationId: number | null,\n  activeConversationId: number | null,\n  approvedRuntimeScriptSkillId: string | null,\n  scope: SessionApprovalScope | null\n): boolean {\n  if (!scope || approvedConversationId === null || activeConversationId === null) {\n    return false\n  }\n\n  if (approvedConversationId !== activeConversationId) {\n    return false\n  }\n\n  if (scope.type === 'write') {\n    return true\n  }\n\n  return scope.type === 'runtime-script-skill' && !!scope.skillId && approvedRuntimeScriptSkillId === scope.skillId\n}\n"
  },
  {
    "path": "src/lib/agent/tool-confirmation-display.ts",
    "content": "export interface ToolConfirmationDisplayConfig {\n  titleKey: string\n  descriptionKey: string\n  summaryFields?: string[]\n  contentFields?: string[]\n  parameterLabels?: Record<string, string>\n}\n\nexport interface ConfirmationPreviewField {\n  name: string\n  labelKey: string\n  value: unknown\n  displayType: 'text' | 'content' | 'json'\n}\n\nexport interface ConfirmationPreview {\n  titleKey: string\n  descriptionKey: string\n  fields: ConfirmationPreviewField[]\n}\n\nconst TOOL_CONFIRMATION_DISPLAY: Record<string, ToolConfirmationDisplayConfig> = {\n  create_file: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.create_file.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.create_file.description',\n    summaryFields: ['filePath', 'content'],\n    contentFields: ['content'],\n  },\n  create_files_batch: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.create_files_batch.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.create_files_batch.description',\n    summaryFields: ['files'],\n    contentFields: ['files'],\n  },\n  rename_file: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.rename_file.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.rename_file.description',\n    summaryFields: ['filePath', 'newName'],\n  },\n  move_file: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.move_file.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.move_file.description',\n    summaryFields: ['sourcePath', 'targetPath'],\n  },\n  copy_file: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.copy_file.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.copy_file.description',\n    summaryFields: ['sourcePath', 'targetPath'],\n  },\n  replace_editor_content: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.replace_editor_content.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.replace_editor_content.description',\n    summaryFields: ['content'],\n    contentFields: ['content'],\n  },\n  insert_at_cursor: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.insert_at_cursor.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.insert_at_cursor.description',\n    summaryFields: ['content'],\n    contentFields: ['content'],\n  },\n  delete_markdown_file: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.delete_markdown_file.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.delete_markdown_file.description',\n    summaryFields: ['filePath'],\n  },\n  execute_skill_script: {\n    titleKey: 'record.chat.input.agent.confirmation.tools.execute_skill_script.title',\n    descriptionKey: 'record.chat.input.agent.confirmation.tools.execute_skill_script.description',\n    summaryFields: ['scriptName', 'command'],\n  },\n}\n\nexport function getToolConfirmationDisplay(toolName: string): ToolConfirmationDisplayConfig | undefined {\n  return TOOL_CONFIRMATION_DISPLAY[toolName]\n}\n\nexport function formatConfirmationPreview(\n  toolName: string,\n  params: Record<string, unknown>\n): ConfirmationPreview {\n  const config = getToolConfirmationDisplay(toolName)\n  const orderedNames = config?.summaryFields?.filter((field) => field in params) ?? []\n  const remainingNames = Object.keys(params).filter((name) => !orderedNames.includes(name))\n  const fieldNames = [...orderedNames, ...remainingNames]\n  const contentFields = new Set(config?.contentFields ?? [])\n\n  return {\n    titleKey: config?.titleKey ?? 'record.chat.input.agent.confirmation.fallback.title',\n    descriptionKey:\n      config?.descriptionKey ?? 'record.chat.input.agent.confirmation.fallback.description',\n    fields: fieldNames.map((name) => ({\n      name,\n      labelKey: `record.chat.input.agent.confirmation.params.${name}`,\n      value: params[name],\n      displayType: contentFields.has(name)\n        ? 'content'\n        : typeof params[name] === 'object' && params[name] !== null\n          ? 'json'\n          : 'text',\n    })),\n  }\n}\n"
  },
  {
    "path": "src/lib/agent/tool-policy.ts",
    "content": "export type ToolRiskLevel = 'low' | 'medium' | 'high'\n\nexport interface IntentPolicy {\n  allowWrite: boolean\n  allowDestructive: boolean\n  allowExecute: boolean\n}\n\nexport interface ToolPolicyEvaluationInput {\n  toolName: string\n  category: string\n  intentPolicy: IntentPolicy\n}\n\nexport interface ToolPolicyEvaluationResult {\n  allowed: boolean\n  requiresConfirmation: boolean\n  reason?: string\n}\n\nexport const HIGH_RISK_TOOLS = new Set([\n  'execute_skill_script',\n  'delete_markdown_file',\n  'delete_markdown_files_batch',\n  'delete_folder',\n  'delete_folders_batch',\n  'delete_tag',\n  'delete_mark',\n  'delete_marks_batch',\n  'delete_chat',\n  'delete_chats_batch',\n  'clear_chats',\n  'clear_all_memories',\n  'delete_memory',\n])\n\nexport const MEDIUM_RISK_TOOLS = new Set([\n  'create_file',\n  'create_files_batch',\n  'create_mark',\n  'create_marks_batch',\n  'update_mark',\n  'update_marks_batch',\n  'create_tag',\n  'update_tag',\n  'create_chat',\n  'create_chats_batch',\n  'update_chat',\n  'update_chats_batch',\n  'insert_at_cursor',\n  'replace_editor_content',\n  'rename_file',\n  'move_file',\n  'copy_file',\n  'rename_files_batch',\n  'move_files_batch',\n  'copy_files_batch',\n])\n\nexport const READ_ONLY_TOOLS = new Set([\n  'select_skill',\n  'load_skill_content',\n  'get_editor_selection',\n  'get_editor_content',\n  'get_current_time',\n  'check_folder_exists',\n  'list_folders',\n  'list_markdown_files',\n  'read_markdown_file',\n  'read_marks',\n  'read_chats',\n  'read_tags',\n])\n\nexport function deriveIntentPolicy(userInput: string): IntentPolicy {\n  const input = userInput.toLowerCase()\n\n  const writePatterns = [\n    /创建|新建|新增|写入|改写|修改|编辑|更新|重写|插入|替换|保存|优化|精简|简化|润色|调整|补充|增加|添加|补全|扩写|完善|丰富|重命名|改名|命名为|移动|复制|草拟|起草|写文章|写内容|生成文章/,\n    /写(一篇|个|篇)?(关于|成|出)?/,\n    /改成|改为/,\n    /\\b(create|write|draft|modify|edit|update|insert|replace|save|rename|move|copy)\\b/i,\n  ]\n  const destructivePatterns = [\n    /删除|移除|清空|清除/,\n    /\\b(delete|remove|clear|wipe|purge)\\b/i,\n  ]\n  const executePatterns = [\n    /执行|运行|命令|脚本|终端|shell|bash|python|node|npm|pnpm/,\n    /\\b(run|execute|command|script|terminal|shell|bash|python|node|npm|pnpm)\\b/i,\n  ]\n  const generativeExecutionPatterns = [\n    /(用|使用).*(skill|技能).*(生成|导出|转换|渲染|构建|产出|输出|保存为)/,\n    /(生成|导出|转换|渲染|构建|产出|输出).*(文件|演示文稿|幻灯片|ppt|pptx|pdf|docx|xlsx)/,\n    /(保存为|输出为|导出为|转换为).*(文件|ppt|pptx|pdf|docx|xlsx)/,\n    /\\b(use .*skill.*(?:generate|export|convert|render|build|produce|save))\\b/i,\n    /\\b(?:generate|export|convert|render|build|produce).*(?:file|presentation|slides|ppt|pptx|pdf|docx|xlsx)\\b/i,\n    /\\b(?:save as|export as|convert to).*(?:ppt|pptx|pdf|docx|xlsx|file)\\b/i,\n  ]\n  const skillExecutionPatterns = [\n    /(用|使用).*(skill|技能).*(生成|导出|转换|制作|渲染|输出)/,\n    /(生成|导出|转换|制作|渲染|输出).*(pptx|pdf|docx|xlsx|图片|演示文稿|文件)/,\n    /\\b(use .*skill.*(?:generate|export|convert|render|build))\\b/i,\n  ]\n  const denyDestructivePatterns = [\n    /不要删除|别删除|禁止删除|不删|不要清空|别清空|禁止清空/,\n    /\\b(do not delete|don't delete|no delete|do not remove|don't remove|do not clear|don't clear)\\b/i,\n  ]\n  const denyExecutePatterns = [\n    /不要执行|别执行|不运行|禁止执行/,\n    /\\b(do not execute|don't execute|do not run|don't run)\\b/i,\n  ]\n\n  const allowWrite =\n    writePatterns.some((pattern) => pattern.test(input)) ||\n    skillExecutionPatterns.some((pattern) => pattern.test(input))\n  const allowDestructive =\n    destructivePatterns.some((pattern) => pattern.test(input)) &&\n    !denyDestructivePatterns.some((pattern) => pattern.test(input))\n  const allowExecute =\n    (executePatterns.some((pattern) => pattern.test(input)) ||\n      generativeExecutionPatterns.some((pattern) => pattern.test(input)) ||\n      skillExecutionPatterns.some((pattern) => pattern.test(input))) &&\n    !denyExecutePatterns.some((pattern) => pattern.test(input))\n\n  return {\n    allowWrite,\n    allowDestructive,\n    allowExecute,\n  }\n}\n\nexport function formatIntentPolicyForPrompt(intentPolicy: IntentPolicy): string {\n  const writeMode = intentPolicy.allowWrite ? 'enabled' : 'disabled'\n  const destructiveMode = intentPolicy.allowDestructive ? 'enabled' : 'disabled'\n  const executeMode = intentPolicy.allowExecute ? 'enabled' : 'disabled'\n\n  return [\n    `- Write mode: ${writeMode}`,\n    `- Destructive mode: ${destructiveMode}`,\n    `- Execute mode: ${executeMode}`,\n    '- If a mode is disabled, do not call related tools; give Final Answer and ask for explicit user confirmation instead.',\n    '- High-risk tools always require confirmation before execution.',\n  ].join('\\n')\n}\n\nexport function isExecuteTool(toolName: string): boolean {\n  return toolName === 'execute_skill_script'\n}\n\nexport function isDestructiveTool(toolName: string): boolean {\n  return (\n    toolName.startsWith('delete_') ||\n    toolName.includes('_delete_') ||\n    toolName.startsWith('clear_') ||\n    toolName.includes('remove')\n  )\n}\n\nfunction isReadOnlyTool(toolName: string): boolean {\n  if (READ_ONLY_TOOLS.has(toolName)) {\n    return true\n  }\n\n  const readPrefixes = ['read_', 'list_', 'search_', 'get_']\n  return readPrefixes.some((prefix) => toolName.startsWith(prefix))\n}\n\nexport function getToolRiskLevel(toolName: string, category: string): ToolRiskLevel {\n  if (HIGH_RISK_TOOLS.has(toolName)) {\n    return 'high'\n  }\n\n  if (MEDIUM_RISK_TOOLS.has(toolName)) {\n    return 'medium'\n  }\n\n  if (READ_ONLY_TOOLS.has(toolName)) {\n    return 'low'\n  }\n\n  if (isExecuteTool(toolName) || isDestructiveTool(toolName)) {\n    return 'high'\n  }\n\n  if (category === 'editor') {\n    if (toolName === 'get_editor_selection' || toolName === 'get_editor_content') {\n      return 'low'\n    }\n    return 'medium'\n  }\n\n  if (isReadOnlyTool(toolName)) {\n    return 'low'\n  }\n\n  return 'medium'\n}\n\nexport function evaluateIntentAwareToolPolicy(\n  input: ToolPolicyEvaluationInput\n): ToolPolicyEvaluationResult {\n  const { toolName, category, intentPolicy } = input\n  const risk = getToolRiskLevel(toolName, category)\n  const isDestructive = isDestructiveTool(toolName)\n  const isExecute = isExecuteTool(toolName)\n\n  if (isExecute && !intentPolicy.allowExecute) {\n    return {\n      allowed: false,\n      requiresConfirmation: false,\n      reason: '用户未明确要求执行命令或脚本',\n    }\n  }\n\n  if (isDestructive && !intentPolicy.allowDestructive) {\n    return {\n      allowed: false,\n      requiresConfirmation: false,\n      reason: '用户未明确要求删除或清空操作',\n    }\n  }\n\n  if (risk === 'medium') {\n    return {\n      allowed: true,\n      requiresConfirmation: true,\n    }\n  }\n\n  if (risk === 'high' && !isDestructive && !isExecute && !intentPolicy.allowWrite) {\n    return {\n      allowed: false,\n      requiresConfirmation: false,\n      reason: '高风险写入操作需要用户明确修改意图',\n    }\n  }\n\n  return {\n    allowed: true,\n    requiresConfirmation: risk === 'high',\n  }\n}\n\nexport function isRecoverableWriteTool(toolName: string, category: string): boolean {\n  const risk = getToolRiskLevel(toolName, category)\n\n  return risk === 'medium' && !isDestructiveTool(toolName) && !isExecuteTool(toolName)\n}\n"
  },
  {
    "path": "src/lib/agent/tools/chat-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { getChats, insertChat, updateChat, deleteChat, clearChatsByTagId, Chat, insertChats, updateChats, deleteChats } from '@/db/chats'\n\nexport const readChatsTool: Tool = {\n  name: 'read_chats',\n  description: 'Read all chat records under the specified tag',\n  category: 'chat',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const chats = await getChats(params.tagId)\n      return {\n        success: true,\n        data: chats,\n        message: `找到 ${chats.length} 条对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `读取对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createChatTool: Tool = {\n  name: 'create_chat',\n  description: 'Create a new chat record',\n  category: 'chat',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'Chat content',\n      required: true,\n    },\n    {\n      name: 'role',\n      type: 'string',\n      description: 'Role: system or user',\n      required: true,\n    },\n    {\n      name: 'type',\n      type: 'string',\n      description: 'Type: chat, note, clipboard, clear',\n      required: false,\n      default: 'chat',\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const chat: Omit<Chat, 'id' | 'createdAt'> = {\n        tagId: params.tagId,\n        content: params.content,\n        role: params.role as 'system' | 'user',\n        type: (params.type || 'chat') as 'chat' | 'note' | 'clipboard' | 'clear',\n        inserted: false,\n      }\n      const result = await insertChat(chat)\n      return {\n        success: true,\n        data: { id: result.lastInsertId },\n        message: `成功创建对话记录，ID: ${result.lastInsertId}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `创建对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateChatTool: Tool = {\n  name: 'update_chat',\n  description: 'Update the specified chat record',\n  category: 'chat',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'Chat record ID',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'New chat content',\n      required: false,\n    },\n    {\n      name: 'inserted',\n      type: 'boolean',\n      description: 'Whether inserted into notes',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const chats = await getChats(params.tagId || 1)\n      const chat = chats.find(c => c.id === params.id)\n      \n      if (!chat) {\n        return {\n          success: false,\n          error: `未找到ID为 ${params.id} 的对话记录`,\n        }\n      }\n      \n      const updatedChat: Chat = {\n        ...chat,\n        content: params.content !== undefined ? params.content : chat.content,\n        inserted: params.inserted !== undefined ? params.inserted : chat.inserted,\n      }\n      \n      await updateChat(updatedChat)\n      return {\n        success: true,\n        message: `成功更新对话记录 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `更新对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteChatTool: Tool = {\n  name: 'delete_chat',\n  description: 'Delete the specified chat record',\n  category: 'chat',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'ID of the chat record to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      await deleteChat(params.id)\n      return {\n        success: true,\n        message: `成功删除对话记录 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `删除对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const clearChatsTool: Tool = {\n  name: 'clear_chats',\n  description: 'Clear all chat records under the specified tag',\n  category: 'chat',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      await clearChatsByTagId(params.tagId)\n      return {\n        success: true,\n        message: `成功清空标签 ${params.tagId} 下的所有对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `清空对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const searchChatsTool: Tool = {\n  name: 'search_chats',\n  description: 'Search chat records for content containing keywords',\n  category: 'search',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'query',\n      type: 'string',\n      description: 'Search keyword',\n      required: true,\n    },\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Optional: limit search to specified tag',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const chats = await getChats(params.tagId || 1)\n      const results = chats.filter(chat => \n        chat.content?.toLowerCase().includes(params.query.toLowerCase())\n      )\n      \n      return {\n        success: true,\n        data: results,\n        message: `找到 ${results.length} 条匹配的对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `搜索对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createChatsBatchTool: Tool = {\n  name: 'create_chats_batch',\n  description: 'Batch create multiple chat records to avoid loop calls. Use for scenarios requiring multiple chat records to be created at once.',\n  category: 'chat',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'chats',\n      type: 'array',\n      description: 'Array of chat records to create, each record contains tagId, content, role, type and other fields',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.chats) || params.chats.length === 0) {\n        return {\n          success: false,\n          error: '参数 chats 必须是非空数组',\n        }\n      }\n\n      const chatsToInsert: Chat[] = params.chats.map((chat: any) => ({\n        id: 0,\n        tagId: chat.tagId,\n        content: chat.content,\n        role: chat.role as 'system' | 'user',\n        type: (chat.type || 'chat') as 'chat' | 'note' | 'clipboard' | 'clear',\n        inserted: false,\n        createdAt: Date.now(),\n      }))\n\n      await insertChats(chatsToInsert)\n      \n      return {\n        success: true,\n        data: { count: chatsToInsert.length },\n        message: `成功批量创建 ${chatsToInsert.length} 条对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量创建对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateChatsBatchTool: Tool = {\n  name: 'update_chats_batch',\n  description: 'Batch update multiple chat records to avoid loop calls. Each record must include the id field.',\n  category: 'chat',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'chats',\n      type: 'array',\n      description: 'Array of chat records to update, each record must include id and fields to update',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.chats) || params.chats.length === 0) {\n        return {\n          success: false,\n          error: '参数 chats 必须是非空数组',\n        }\n      }\n\n      const chatsToUpdate: Chat[] = params.chats.map((chat: any) => ({\n        id: chat.id,\n        tagId: chat.tagId,\n        content: chat.content,\n        role: chat.role,\n        type: chat.type,\n        inserted: chat.inserted ?? false,\n        createdAt: chat.createdAt || Date.now(),\n        image: chat.image,\n        images: chat.images,\n        ragSources: chat.ragSources,\n        agentHistory: chat.agentHistory,\n        thinking: chat.thinking,\n        quoteData: chat.quoteData,\n      }))\n\n      await updateChats(chatsToUpdate)\n      \n      return {\n        success: true,\n        data: { count: chatsToUpdate.length },\n        message: `成功批量更新 ${chatsToUpdate.length} 条对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量更新对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteChatsBatchTool: Tool = {\n  name: 'delete_chats_batch',\n  description: 'Batch delete multiple chat records to avoid loop calls.',\n  category: 'chat',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'ids',\n      type: 'array',\n      description: 'Array of chat record IDs to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.ids) || params.ids.length === 0) {\n        return {\n          success: false,\n          error: '参数 ids 必须是非空数组',\n        }\n      }\n\n      await deleteChats(params.ids)\n      \n      return {\n        success: true,\n        data: { count: params.ids.length },\n        message: `成功批量删除 ${params.ids.length} 条对话记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量删除对话记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const chatTools: Tool[] = [\n  readChatsTool,\n  createChatTool,\n  updateChatTool,\n  deleteChatTool,\n  clearChatsTool,\n  searchChatsTool,\n  createChatsBatchTool,\n  updateChatsBatchTool,\n  deleteChatsBatchTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/editor-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport emitter from '@/lib/emitter'\n\n// 1. 获取当前选中内容\nexport const getEditorSelectionTool: Tool = {\n  name: 'get_editor_selection',\n  description: `📝 **Editor Operation**: Get the currently selected text in the editor, including position information.\n\n**Use Cases:**\n- Get selected text for AI processing (translate, polish, etc.)\n- Know selection range for precise replacement\n- Get line numbers for line-based editing\n\n**Returns:**\n- \\`text\\`: Selected text content\n- \\`from\\`: Start position (0-indexed)\n- \\`to\\`: End position (0-indexed)\n- \\`startLine\\`: Start line number (1-indexed)\n- \\`endLine\\`: End line number (1-indexed)`,\n  category: 'editor',\n  requiresConfirmation: false,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    return new Promise((resolve) => {\n      emitter.emit('editor-get-selection', {\n        resolve: (data) => {\n          resolve({\n            success: !!data.text,\n            data,\n            message: data.text\n              ? `选中内容：${data.text.slice(0, 50)}${data.text.length > 50 ? '...' : ''} (行 ${data.startLine}-${data.endLine})`\n              : '当前没有选中文本',\n          })\n        },\n      })\n    })\n  },\n}\n\n// 2. 获取当前编辑器内容\nexport const getEditorContentTool: Tool = {\n  name: 'get_editor_content',\n  description: `📝 **Editor Operation**: Get the current complete content of the editor (unsaved changes included).\n\n**Use Cases:**\n- Get current editor state for AI analysis\n- Read unsaved changes that haven't been saved to file\n- Get total line count for line-based editing\n\n**Returns:**\n- \\`markdown\\`: Full markdown content\n- \\`wordCount\\`: Number of words\n- \\`charCount\\`: Number of characters\n- \\`totalLines\\`: Total number of lines\n- \\`numberedLines\\`: The current content rendered line by line with 1-based line numbers\n- \\`version\\`: Version number for content verification (use this when calling replace_editor_content)\n\n**Recommended workflow for document-wide edits:** Read \\`numberedLines\\`, then call \\`replace_editor_content\\` with \\`startLine: 1\\`, \\`endLine: totalLines\\`, and \\`version\\`.\n\n**Note:** Use read_markdown_file if you need the saved file content.`,\n  category: 'editor',\n  requiresConfirmation: false,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    return new Promise((resolve) => {\n      emitter.emit('editor-get-content', {\n        resolve: (data: { markdown: string; html?: string; text: string; wordCount: number; charCount: number; totalLines?: number; numberedLines?: string; version: number }) => {\n          resolve({\n            success: true,\n            data: {\n              ...data,\n              version: data.version,\n            },\n            message: `编辑器内容：${data.markdown.slice(0, 50)}${data.markdown.length > 50 ? '...' : ''} (${data.wordCount} 字，${data.totalLines || '?'} 行, v${data.version})`,\n          })\n        },\n      })\n    })\n  },\n}\n\n// 3. 在光标位置插入内容\nexport const insertAtCursorTool: Tool = {\n  name: 'insert_at_cursor',\n  description: `📝 **Editor Operation**: Insert content at the current cursor position or replace selected text.\n\n**Use Cases:**\n- AI generates content and wants to insert at cursor\n- Insert AI response after user's selected text\n\n**Parameters:**\n- \\`content\\`: Content to insert (Markdown format supported)\n- \\`replaceSelection\\`: If true, replaces current selection; default false (inserts at cursor)`,\n  category: 'editor',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'content',\n      type: 'string',\n      description: 'Content to insert (Markdown format)',\n      required: true,\n    },\n    {\n      name: 'replaceSelection',\n      type: 'boolean',\n      description: 'If true, replaces current selection; default false',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    return new Promise((resolve) => {\n      emitter.emit('editor-insert', {\n        content: params.content,\n        resolve: (result) => {\n          resolve({\n            success: result.success,\n            data: result,\n            message: result.success\n              ? `成功插入 ${result.insertedLength} 个字符`\n              : '插入失败',\n          })\n        },\n      })\n    })\n  },\n}\n\n// 4. 替换指定范围的内容\nexport const replaceEditorContentTool: Tool = {\n  name: 'replace_editor_content',\n  description: `📝 **Editor Operation**: Replace content in the specified range with new content.\n\n**IMPORTANT - Prefer Exact Quoted Range**:\nWhen the user quotes content from the editor and exact selection positions are provided, you MUST use position-based mode (\\`from\\`/\\`to\\`) so that only the quoted selection is replaced.\n- If quote context includes \\`from\\` and \\`to\\`, use them directly\n- Only use line-based mode (\\`startLine\\`/\\`endLine\\`) when exact positions are not available\n- NEVER expand a quoted edit to the whole document\n\n**Use Cases:**\n- AI wants to modify specific lines/paragraphs\n- Precise content replacement based on selection or text search\n- Replace specific text throughout the document\n\n**Parameters (choose one of these modes):**\n\n**Mode 1: Line-based (fallback when exact positions are unavailable)**\n- \\`startLine\\`: Start line number (1-based, required for line-based mode)\n- \\`endLine\\`: End line number (1-based, required for line-based mode)\n- \\`replaceContent\\`: New content to replace with\n\n**Mode 2: Text-based search**\n- \\`searchContent\\`: Text to search for (must match exactly)\n- \\`replaceContent\\`: New content to replace with\n- \\`occurrence\\`: Which occurrence to replace (1-based, default: 1)\n\n**Mode 3: Position-based (RECOMMENDED for quoted editor selections)**\n- \\`content\\`: New content to replace with\n- \\`from\\`: Start position (0-indexed, optional)\n- \\`to\\`: End position (0-indexed, optional)\n\n**Note:** Use \\`get_editor_content\\` only when necessary. Prefer exact quoted positions (\\`from\\`/\\`to\\`) when they are available from the user's selection.`,\n  category: 'editor',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'startLine',\n      type: 'number',\n      description: 'Start line number (1-based, REQUIRED when user quotes content)',\n      required: false,\n    },\n    {\n      name: 'endLine',\n      type: 'number',\n      description: 'End line number (1-based, REQUIRED when user quotes content)',\n      required: false,\n    },\n    {\n      name: 'replaceContent',\n      type: 'string',\n      description: 'New content to replace with (text-based/line-based mode)',\n      required: false,\n    },\n    {\n      name: 'searchContent',\n      type: 'string',\n      description: 'Text to search for (text-based mode)',\n      required: false,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'New content to replace with (position-based mode)',\n      required: false,\n    },\n    {\n      name: 'from',\n      type: 'number',\n      description: 'Start position (0-indexed, optional)',\n      required: false,\n    },\n    {\n      name: 'to',\n      type: 'number',\n      description: 'End position (0-indexed, optional)',\n      required: false,\n    },\n    {\n      name: 'occurrence',\n      type: 'number',\n      description: 'Which occurrence to replace (1-based, default: 1)',\n      required: false,\n    },\n    {\n      name: 'version',\n      type: 'number',\n      description: 'Version number from get_editor_content (to ensure content has not changed, highly recommended)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    return new Promise((resolve) => {\n      // 确定使用哪种模式\n      const hasPositionParams = params.from !== undefined || params.to !== undefined;\n      const hasSearchParams = params.searchContent;\n      const hasLineParams = params.startLine !== undefined && params.endLine !== undefined;\n\n      if (!hasPositionParams && !hasSearchParams && !hasLineParams && !params.content) {\n        resolve({\n          success: false,\n          error: 'Missing required parameters',\n          message: '请提供 content 或 searchContent 或 startLine/endLine 参数',\n        });\n        return;\n      }\n\n      emitter.emit('editor-replace', {\n        content: params.content || params.replaceContent,\n        range: (params.from !== undefined && params.to !== undefined)\n          ? { from: params.from, to: params.to }\n          : undefined,\n        searchContent: params.searchContent,\n        occurrence: params.occurrence || 1,\n        startLine: params.startLine,\n        endLine: params.endLine,\n        expectedVersion: params.version,\n        resolve: (result) => {\n          if (result.versionMismatch) {\n            resolve({\n              success: false,\n              error: result.error,\n              message: '编辑器内容已变化，请重新获取内容后再操作',\n            });\n          } else if (result.success) {\n            resolve({\n              success: true,\n              data: result,\n              message: result.message || `成功替换 ${result.insertedLength} 个字符`,\n            });\n          } else {\n            resolve({\n              success: false,\n              error: result.error,\n              message: result.message || '替换失败',\n            });\n          }\n        },\n      });\n    });\n  },\n}\n\nexport const editorTools: Tool[] = [\n  getEditorSelectionTool,\n  getEditorContentTool,\n  insertAtCursorTool,\n  replaceEditorContentTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/folder-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { mkdir, remove, exists, readDir } from '@tauri-apps/plugin-fs'\nimport { getWorkspacePath, getFilePathOptions, normalizeWorkspaceRelativePath } from '@/lib/workspace'\nimport { join } from '@tauri-apps/api/path'\nimport useArticleStore from '@/stores/article'\n\nexport const checkFolderExistsTool: Tool = {\n  name: 'check_folder_exists',\n  description: 'Check if the specified folder exists',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Folder path to check (relative to notes root directory, e.g., \"frontend/React\" or \"study-notes\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const normalizedFolderPath = await normalizeWorkspaceRelativePath(params.folderPath)\n      const workspace = await getWorkspacePath()\n\n      let fullPath = ''\n      let folderExists = false\n\n      if (workspace.isCustom) {\n        fullPath = await join(workspace.path, normalizedFolderPath)\n        folderExists = await exists(fullPath)\n      } else {\n        const { path, baseDir } = await getFilePathOptions(normalizedFolderPath)\n        fullPath = path\n        folderExists = await exists(fullPath, { baseDir })\n      }\n\n      return {\n        success: true,\n        data: {\n          folderPath: normalizedFolderPath,\n          exists: folderExists,\n          fullPath,\n        },\n        message: folderExists\n          ? `文件夹 \"${normalizedFolderPath}\" 存在`\n          : `文件夹 \"${normalizedFolderPath}\" 不存在`,\n      }\n    } catch (error) {\n      console.error('[check_folder_exists] 检查失败', {\n        folderPath: params.folderPath,\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `检查文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createFolderTool: Tool = {\n  name: 'create_folder',\n  description: 'Create a new folder for organizing notes',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Folder path (relative to notes root directory, e.g., \"frontend/React\" or \"study-notes\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // 验证必需参数\n      if (!params.folderPath || typeof params.folderPath !== 'string') {\n        return {\n          success: false,\n          error: '缺少必需参数 folderPath 或参数类型错误',\n        }\n      }\n\n      const normalizedFolderPath = await normalizeWorkspaceRelativePath(params.folderPath)\n\n      const workspace = await getWorkspacePath()\n\n      if (workspace.isCustom) {\n        // 自定义工作区：使用绝对路径\n        const fullPath = await join(workspace.path, normalizedFolderPath)\n        \n        // 检查文件夹是否已存在\n        const folderExists = await exists(fullPath)\n        if (folderExists) {\n          // 文件夹已存在，视为成功\n          return {\n            success: true,\n            data: { folderPath: normalizedFolderPath, alreadyExists: true },\n            message: `文件夹已存在: ${normalizedFolderPath}`,\n          }\n        }\n\n        // 创建文件夹\n        await mkdir(fullPath, { recursive: true })\n      } else {\n        // 默认工作区：使用 baseDir\n        const { path, baseDir } = await getFilePathOptions(normalizedFolderPath)\n        \n        // 检查文件夹是否已存在\n        const folderExists = await exists(path, { baseDir })\n        if (folderExists) {\n          // 文件夹已存在，视为成功\n          return {\n            success: true,\n            data: { folderPath: normalizedFolderPath, alreadyExists: true },\n            message: `文件夹已存在: ${normalizedFolderPath}`,\n          }\n        }\n\n        // 创建文件夹\n        await mkdir(path, { baseDir, recursive: true })\n      }\n\n      const articleStore = useArticleStore.getState()\n      const inserted = articleStore.insertLocalEntry(normalizedFolderPath, true)\n      await articleStore.ensurePathExpanded(normalizedFolderPath)\n      if (!inserted) {\n        await articleStore.loadFileTree()\n      }\n\n      return {\n        success: true,\n        data: { folderPath: normalizedFolderPath },\n        message: `成功创建文件夹: ${normalizedFolderPath}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `创建文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteFolderTool: Tool = {\n  name: 'delete_folder',\n  description: 'Delete the specified folder (will delete all contents within the folder)',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Path of the folder to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // 验证必需参数\n      if (!params.folderPath || typeof params.folderPath !== 'string') {\n        return {\n          success: false,\n          error: '缺少必需参数 folderPath 或参数类型错误',\n        }\n      }\n\n      const normalizedFolderPath = await normalizeWorkspaceRelativePath(params.folderPath)\n\n      const workspace = await getWorkspacePath()\n\n      if (workspace.isCustom) {\n        // 自定义工作区：使用绝对路径\n        const fullPath = await join(workspace.path, normalizedFolderPath)\n        \n        // 检查文件夹是否存在\n        const folderExists = await exists(fullPath)\n        if (!folderExists) {\n          return {\n            success: false,\n            error: `文件夹不存在: ${normalizedFolderPath}`,\n          }\n        }\n\n        // 删除文件夹\n        await remove(fullPath, { recursive: true })\n      } else {\n        // 默认工作区：使用 baseDir\n        const { path, baseDir } = await getFilePathOptions(normalizedFolderPath)\n        \n        // 检查文件夹是否存在\n        const folderExists = await exists(path, { baseDir })\n        if (!folderExists) {\n          return {\n            success: false,\n            error: `文件夹不存在: ${normalizedFolderPath}`,\n          }\n        }\n\n        // 删除文件夹\n        await remove(path, { baseDir, recursive: true })\n      }\n\n      const articleStore = useArticleStore.getState()\n      const removed = articleStore.removeLocalEntry(normalizedFolderPath)\n      if (!removed) {\n        await articleStore.loadFileTree()\n      }\n\n      await articleStore.cleanTabsByDeletedFolder(normalizedFolderPath)\n\n      if (articleStore.activeFilePath && articleStore.activeFilePath.startsWith(`${normalizedFolderPath}/`)) {\n        await articleStore.setActiveFilePath('')\n        articleStore.setCurrentArticle('')\n      }\n\n      return {\n        success: true,\n        message: `成功删除文件夹: ${normalizedFolderPath}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `删除文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const listFoldersTool: Tool = {\n  name: 'list_folders',\n  description: 'List all folders under the specified path',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Folder path to list, leave empty for root directory',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const workspace = await getWorkspacePath()\n\n      if (workspace.isCustom) {\n        // 自定义工作区：使用绝对路径\n        const fullPath = params.folderPath\n          ? await join(workspace.path, params.folderPath)\n          : workspace.path\n\n        // 检查路径是否存在\n        const pathExists = await exists(fullPath)\n\n        if (!pathExists) {\n          return {\n            success: false,\n            error: `路径不存在: ${params.folderPath || '根目录'}`,\n          }\n        }\n\n        // 读取目录内容\n        const entries = await readDir(fullPath)\n\n        // 过滤出文件夹\n        const folders = entries\n          .filter(entry => entry.isDirectory)\n          .map(entry => ({\n            name: entry.name,\n            path: params.folderPath ? `${params.folderPath}/${entry.name}` : entry.name,\n          }))\n\n        return {\n          success: true,\n          data: folders,\n          message: `找到 ${folders.length} 个文件夹`,\n        }\n      } else {\n        // 默认工作区：使用 baseDir\n        const { path, baseDir } = await getFilePathOptions(params.folderPath || '')\n\n        // 检查路径是否存在\n        const pathExists = await exists(path, { baseDir })\n\n        if (!pathExists) {\n          return {\n            success: false,\n            error: `路径不存在: ${params.folderPath || '根目录'}`,\n          }\n        }\n\n        // 读取目录内容\n        const entries = await readDir(path, { baseDir })\n\n        // 过滤出文件夹\n        const folders = entries\n          .filter(entry => entry.isDirectory)\n          .map(entry => ({\n            name: entry.name,\n            path: params.folderPath ? `${params.folderPath}/${entry.name}` : entry.name,\n          }))\n\n        return {\n          success: true,\n          data: folders,\n          message: `找到 ${folders.length} 个文件夹`,\n        }\n      }\n    } catch (error) {\n      console.error('[list_folders] 执行失败', {\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `列出文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createFoldersBatchTool: Tool = {\n  name: 'create_folders_batch',\n  description: 'Batch create multiple folders to avoid loop calls. Use for scenarios requiring multiple folders to be created at once.',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'folderPaths',\n      type: 'array',\n      description: 'Array of folder paths to create',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.folderPaths) || params.folderPaths.length === 0) {\n        return {\n          success: false,\n          error: '参数 folderPaths 必须是非空数组',\n        }\n      }\n\n      const workspace = await getWorkspacePath()\n      const created = []\n      const skipped = []  // 已存在，跳过创建\n      const errors = []   // 真正的错误\n\n      for (const folderPath of params.folderPaths) {\n        try {\n          if (workspace.isCustom) {\n            const fullPath = await join(workspace.path, folderPath)\n            const folderExists = await exists(fullPath)\n            if (folderExists) {\n              skipped.push({ path: folderPath, reason: '文件夹已存在' })\n              continue\n            }\n            await mkdir(fullPath, { recursive: true })\n          } else {\n            const { path, baseDir } = await getFilePathOptions(folderPath)\n            const folderExists = await exists(path, { baseDir })\n            if (folderExists) {\n              skipped.push({ path: folderPath, reason: '文件夹已存在' })\n              continue\n            }\n            await mkdir(path, { baseDir, recursive: true })\n          }\n          created.push(folderPath)\n        } catch (error) {\n          errors.push({ path: folderPath, error: String(error) })\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      await articleStore.loadFileTree()\n\n      // 只要有任何真正错误，就标记为失败状态（已存在的 skipped 不算错误）\n      return {\n        success: errors.length === 0,\n        data: {\n          created,\n          skipped,\n          errors,\n          createdCount: created.length,\n          skippedCount: skipped.length,\n          errorCount: errors.length,\n        },\n        message: errors.length === 0\n          ? `创建 ${created.length} 个，跳过 ${skipped.length} 个`\n          : `部分失败：创建 ${created.length} 个，跳过 ${skipped.length} 个，${errors.length} 个失败`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量创建文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteFoldersBatchTool: Tool = {\n  name: 'delete_folders_batch',\n  description: 'Batch delete multiple folders (will delete all contents within the folders) to avoid loop calls.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'folderPaths',\n      type: 'array',\n      description: 'Array of folder paths to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.folderPaths) || params.folderPaths.length === 0) {\n        return {\n          success: false,\n          error: '参数 folderPaths 必须是非空数组',\n        }\n      }\n\n      const workspace = await getWorkspacePath()\n      const results = []\n      const errors = []\n\n      for (const folderPath of params.folderPaths) {\n        try {\n          if (workspace.isCustom) {\n            const fullPath = await join(workspace.path, folderPath)\n            const folderExists = await exists(fullPath)\n            if (!folderExists) {\n              errors.push({ path: folderPath, error: '文件夹不存在' })\n              continue\n            }\n            await remove(fullPath, { recursive: true })\n          } else {\n            const { path, baseDir } = await getFilePathOptions(folderPath)\n            const folderExists = await exists(path, { baseDir })\n            if (!folderExists) {\n              errors.push({ path: folderPath, error: '文件夹不存在' })\n              continue\n            }\n            await remove(path, { baseDir, recursive: true })\n          }\n          results.push(folderPath)\n        } catch (error) {\n          errors.push({ path: folderPath, error: String(error) })\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      await articleStore.loadFileTree()\n\n      // 只要有任何文件夹删除失败，就标记为失败状态\n      return {\n        success: errors.length === 0,\n        data: {\n          deleted: results,\n          failed: errors,\n          successCount: results.length,\n          failCount: errors.length,\n        },\n        message: errors.length === 0\n          ? `成功删除 ${results.length} 个文件夹`\n          : `部分失败：成功删除 ${results.length} 个文件夹，${errors.length} 个失败`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量删除文件夹失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const folderTools: Tool[] = [\n  checkFolderExistsTool,\n  createFolderTool,\n  deleteFolderTool,\n  listFoldersTool,\n  createFoldersBatchTool,\n  deleteFoldersBatchTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/index.ts",
    "content": "import { Tool } from '../types'\nimport { noteTools } from './note-tools'\nimport { chatTools } from './chat-tools'\nimport { tagTools } from './tag-tools'\nimport { markTools } from './mark-tools'\nimport { folderTools } from './folder-tools'\nimport { systemTools } from './system-tools'\nimport { memoryTools } from './memory-tools'\nimport { editorTools } from './editor-tools'\n\nexport const allTools: Tool[] = [\n  ...noteTools,\n  ...chatTools,\n  ...tagTools,\n  ...markTools,\n  ...folderTools,\n  ...systemTools,\n  ...memoryTools,\n  ...editorTools,\n]\n\n/**\n * Convert MCP tools to Agent tool format\n * @param serverId MCP server ID\n * @param tool MCP tool definition\n * @returns Agent tool\n */\nfunction convertMcpToolToAgentTool(serverId: string, tool: any): Tool {\n  // Parse parameters\n  const parameters = Object.entries(tool.inputSchema?.properties || {}).map(([name, schema]: [string, any]) => ({\n    name,\n    type: mapJsonSchemaTypeToToolType(schema.type),\n    description: schema.description || name,\n    required: tool.inputSchema?.required?.includes(name) || false,\n  }))\n\n  // Enhance tool description to help AI better understand the tool's purpose\n  const enhancedDescription = tool.description || tool.name\n\n  return {\n    name: `${serverId}__${tool.name}`,\n    description: enhancedDescription,\n    parameters,\n    requiresConfirmation: false,\n    category: 'mcp',\n    execute: async (params: Record<string, any>) => {\n      try {\n        const { callTool } = await import('@/lib/mcp/tools')\n        const result = await callTool(serverId, tool.name, params)\n\n        if (result.isError) {\n          return {\n            success: false,\n            error: result.content.map((c: any) => c.text).join('\\n'),\n          }\n        }\n\n        return {\n          success: true,\n          data: result.content,\n          message: result.content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\\n'),\n        }\n      } catch (error) {\n        return {\n          success: false,\n          error: error instanceof Error ? error.message : String(error),\n        }\n      }\n    },\n  }\n}\n\n/**\n * Map JSON Schema types to tool parameter types\n */\nfunction mapJsonSchemaTypeToToolType(jsonType: string): Tool['parameters'][0]['type'] {\n  const typeMap: Record<string, Tool['parameters'][0]['type']> = {\n    string: 'string',\n    number: 'number',\n    integer: 'number',\n    boolean: 'boolean',\n    array: 'array',\n    object: 'object',\n  }\n  return typeMap[jsonType] || 'string'\n}\n\n/**\n * Get all tools, including MCP tools (if there are selected servers)\n */\nexport function getAllTools(): Tool[] {\n  const tools = [...allTools]\n\n  // Dynamically add MCP tools\n  // Note: due to circular dependency issues, cannot use import directly here\n  // MCP tools will be added at runtime through dynamic loading\n  // Return base tool list here\n  return tools\n}\n\n// MCP tools cache\nlet mcpToolsCache: Tool[] = []\nlet mcpToolsLoaded = false\n\n/**\n * Get all tools, including MCP tools (async version)\n * This function is used for scenarios that need to load MCP tools\n */\nexport async function getAllToolsAsync(): Promise<Tool[]> {\n  const tools = [...allTools]\n\n  // Dynamically add MCP tools\n  try {\n    const { useMcpStore } = await import('@/stores/mcp')\n    const { mcpServerManager } = await import('@/lib/mcp/server-manager')\n\n    const mcpStore = useMcpStore.getState()\n\n    if (mcpStore.selectedServerIds.length === 0) {\n      mcpToolsLoaded = true\n      return tools\n    }\n\n    for (const serverId of mcpStore.selectedServerIds) {\n      const mcpTools = mcpServerManager.getServerTools(serverId)\n\n      for (const mcpTool of mcpTools) {\n        const agentTool = convertMcpToolToAgentTool(serverId, mcpTool)\n        tools.push(agentTool)\n        mcpToolsCache.push(agentTool)\n      }\n    }\n    mcpToolsLoaded = true\n  } catch (error) {\n    console.error('[Agent MCP] Failed to load MCP tools:', error)\n  }\n\n  return tools\n}\n\n/**\n * Get tools (including loaded MCP tools)\n */\nexport function getAllToolsSync(): Tool[] {\n  if (mcpToolsLoaded) {\n    return [...allTools, ...mcpToolsCache]\n  }\n  return allTools\n}\n\n/**\n * Reload MCP tools\n */\nexport async function reloadMcpTools(): Promise<void> {\n  mcpToolsCache = []\n  mcpToolsLoaded = false\n  await getAllToolsAsync()\n}\n\nexport function getToolByName(name: string): Tool | undefined {\n  return getAllToolsSync().find(tool => tool.name === name)\n}\n\nexport function getToolsByCategory(category: Tool['category']): Tool[] {\n  return allTools.filter(tool => tool.category === category)\n}\n\nexport function getToolDescriptions(): string {\n  return getAllToolsSync().map(tool => {\n    const params = tool.parameters.map(p =>\n      `  - ${p.name} (${p.type}${p.required ? ', required' : ', optional'}): ${p.description}`\n    ).join('\\n')\n\n    return `### ${tool.name}\n${tool.description}\nCategory: ${tool.category}\nRequires Confirmation: ${tool.requiresConfirmation ? 'Yes' : 'No'}\nParameters:\n${params || '  None'}\n`\n  }).join('\\n\\n')\n}\n\nexport * from './note-tools'\nexport * from './chat-tools'\nexport * from './tag-tools'\nexport * from './mark-tools'\nexport * from './folder-tools'\nexport * from './system-tools'\nexport * from './memory-tools'\nexport * from './editor-tools'\n"
  },
  {
    "path": "src/lib/agent/tools/mark-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { getMarks, getAllMarks, insertMark, updateMark, delMark, restoreMark, Mark, insertMarks, updateMarks, deleteMarks, restoreMarks } from '@/db/marks'\nimport useTagStore from '@/stores/tag'\n\n/**\n * 获取当前选中的标签ID\n * 如果用户没有明确指定标签，使用当前选中的标签\n */\nfunction getCurrentTagId(tagId?: number): number {\n  // 如果明确传入了 tagId，使用传入的值\n  if (tagId !== undefined && tagId !== null) {\n    return tagId\n  }\n  // 否则使用当前选中的标签\n  return useTagStore.getState().currentTagId\n}\n\nexport const readMarksTool: Tool = {\n  name: 'read_marks',\n  description: 'Read all content records (marks) under a specific tag. Uses current selected tag if tagId not specified.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID (optional, uses current selected tag if not specified)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const tagId = getCurrentTagId(params.tagId)\n      const marks = await getMarks(tagId)\n      const activeMarks = marks.filter(m => m.deleted === 0)\n      return {\n        success: true,\n        data: activeMarks,\n        message: `找到 ${activeMarks.length} 条记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `读取记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createMarkTool: Tool = {\n  name: 'create_mark',\n  description: 'Create a new content record (mark) under a specific tag.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID (use list_tags first to get available tags)',\n      required: true,\n    },\n    {\n      name: 'type',\n      type: 'string',\n      description: 'Mark type: scan (OCR), text, image, link, file, recording',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'Main content of the mark (text, OCR result, etc.)',\n      required: false,\n    },\n    {\n      name: 'url',\n      type: 'string',\n      description: 'Related URL or file path',\n      required: false,\n    },\n    {\n      name: 'desc',\n      type: 'string',\n      description: 'Brief description or title',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const mark: Partial<Mark> = {\n        tagId: params.tagId,\n        type: params.type as 'scan' | 'text' | 'image' | 'link' | 'file' | 'recording',\n        content: params.content,\n        url: params.url || '',\n        desc: params.desc,\n      }\n      const result = await insertMark(mark)\n      return {\n        success: true,\n        data: { id: result.lastInsertId },\n        message: `成功创建记录，ID: ${result.lastInsertId}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `创建记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateMarkTool: Tool = {\n  name: 'update_mark',\n  description: 'Update content of an existing mark.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'Mark ID (use read_marks first to get mark IDs)',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'New content',\n      required: false,\n    },\n    {\n      name: 'desc',\n      type: 'string',\n      description: 'New description',\n      required: false,\n    },\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Move to new tag (optional)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const marks = await getMarks(params.tagId || 1)\n      const mark = marks.find(m => m.id === params.id)\n      \n      if (!mark) {\n        return {\n          success: false,\n          error: `未找到ID为 ${params.id} 的记录`,\n        }\n      }\n      \n      const updatedMark: Mark = {\n        ...mark,\n        content: params.content !== undefined ? params.content : mark.content,\n        desc: params.desc !== undefined ? params.desc : mark.desc,\n        tagId: params.tagId !== undefined ? params.tagId : mark.tagId,\n      }\n      \n      await updateMark(updatedMark)\n      return {\n        success: true,\n        message: `成功更新记录 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `更新记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteMarkTool: Tool = {\n  name: 'delete_mark',\n  description: 'Soft delete a mark. Can be restored with restore_mark.',\n  category: 'mark',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'Mark ID to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      await delMark(params.id)\n      return {\n        success: true,\n        message: `成功删除记录 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `删除记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const restoreMarkTool: Tool = {\n  name: 'restore_mark',\n  description: 'Restore deleted marks',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'ID of the mark to restore',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      await restoreMark(params.id)\n      return {\n        success: true,\n        message: `成功恢复记录 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `恢复记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const searchMarksTool: Tool = {\n  name: 'search_marks',\n  description: 'Search content within marks (database records). Uses current selected tag if tagId not specified.',\n  category: 'search',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'query',\n      type: 'string',\n      description: 'Search keyword',\n      required: true,\n    },\n    {\n      name: 'tagId',\n      type: 'number',\n      description: 'Tag ID (optional, uses current selected tag if not specified)',\n      required: false,\n    },\n    {\n      name: 'type',\n      type: 'string',\n      description: 'Optional: filter by mark type (scan, text, image, link, file, recording)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const tagId = getCurrentTagId(params.tagId)\n      const marks = await getMarks(tagId)\n      let results = marks.filter(mark =>\n        mark.deleted === 0 &&\n        (mark.content?.toLowerCase().includes(params.query.toLowerCase()) ||\n         mark.desc?.toLowerCase().includes(params.query.toLowerCase()))\n      )\n\n      if (params.type) {\n        results = results.filter(mark => mark.type === params.type)\n      }\n\n      return {\n        success: true,\n        data: results,\n        message: `找到 ${results.length} 条匹配的记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `搜索记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const searchAllMarksTool: Tool = {\n  name: 'search_all_marks',\n  description: 'Search ALL marks across ALL tags for keywords.',\n  category: 'search',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'query',\n      type: 'string',\n      description: 'Search keyword',\n      required: true,\n    },\n    {\n      name: 'mode',\n      type: 'string',\n      description: 'Search mode: fuzzy (default, contains keyword) or exact (exact match)',\n      required: false,\n    },\n    {\n      name: 'type',\n      type: 'string',\n      description: 'Optional: filter by mark type (scan, text, image, link, file, recording)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const allMarks = await getAllMarks()\n      const queryLower = params.query.toLowerCase()\n\n      let results = allMarks.filter(mark => {\n        if (mark.deleted === 1) return false\n\n        const contentMatch = params.mode === 'exact'\n          ? mark.content?.toLowerCase() === queryLower\n          : mark.content?.toLowerCase().includes(queryLower)\n        const descMatch = params.mode === 'exact'\n          ? mark.desc?.toLowerCase() === queryLower\n          : mark.desc?.toLowerCase().includes(queryLower)\n\n        return contentMatch || descMatch\n      })\n\n      if (params.type) {\n        results = results.filter(mark => mark.type === params.type)\n      }\n\n      return {\n        success: true,\n        data: results,\n        message: `在所有标签中找到 ${results.length} 条匹配的记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `搜索所有记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createMarksBatchTool: Tool = {\n  name: 'create_marks_batch',\n  description: 'Batch create multiple marks to avoid loop calls. Use for scenarios requiring multiple marks to be created at once.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'marks',\n      type: 'array',\n      description: 'Array of marks to create, each mark contains tagId, type, content, url, desc and other fields',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.marks) || params.marks.length === 0) {\n        return {\n          success: false,\n          error: '参数 marks 必须是非空数组',\n        }\n      }\n\n      const marksToInsert: Partial<Mark>[] = params.marks.map((mark: any) => ({\n        tagId: mark.tagId,\n        type: mark.type as 'scan' | 'text' | 'image' | 'link' | 'file' | 'recording',\n        content: mark.content,\n        url: mark.url || '',\n        desc: mark.desc,\n        createdAt: Date.now(),\n        deleted: 0,\n      }))\n\n      await insertMarks(marksToInsert)\n      \n      return {\n        success: true,\n        data: { count: marksToInsert.length },\n        message: `成功批量创建 ${marksToInsert.length} 条记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量创建记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateMarksBatchTool: Tool = {\n  name: 'update_marks_batch',\n  description: 'Batch update multiple marks to avoid loop calls. Each mark must include the id field.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'marks',\n      type: 'array',\n      description: 'Array of marks to update, each mark must include id and fields to update',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.marks) || params.marks.length === 0) {\n        return {\n          success: false,\n          error: '参数 marks 必须是非空数组',\n        }\n      }\n\n      const marksToUpdate: Mark[] = params.marks.map((mark: any) => ({\n        id: mark.id,\n        tagId: mark.tagId,\n        type: mark.type,\n        content: mark.content,\n        url: mark.url,\n        desc: mark.desc,\n        deleted: mark.deleted ?? 0,\n        createdAt: mark.createdAt || Date.now(),\n      }))\n\n      await updateMarks(marksToUpdate)\n      \n      return {\n        success: true,\n        data: { count: marksToUpdate.length },\n        message: `成功批量更新 ${marksToUpdate.length} 条记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量更新记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteMarksBatchTool: Tool = {\n  name: 'delete_marks_batch',\n  description: 'Batch delete multiple marks (soft delete, can be restored) to avoid loop calls.',\n  category: 'mark',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'ids',\n      type: 'array',\n      description: 'Array of mark IDs to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.ids) || params.ids.length === 0) {\n        return {\n          success: false,\n          error: '参数 ids 必须是非空数组',\n        }\n      }\n\n      await deleteMarks(params.ids)\n      \n      return {\n        success: true,\n        data: { count: params.ids.length },\n        message: `成功批量删除 ${params.ids.length} 条记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量删除记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const restoreMarksBatchTool: Tool = {\n  name: 'restore_marks_batch',\n  description: 'Batch restore deleted marks to avoid loop calls.',\n  category: 'mark',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'ids',\n      type: 'array',\n      description: 'Array of mark IDs to restore',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.ids) || params.ids.length === 0) {\n        return {\n          success: false,\n          error: '参数 ids 必须是非空数组',\n        }\n      }\n\n      await restoreMarks(params.ids)\n      \n      return {\n        success: true,\n        data: { count: params.ids.length },\n        message: `成功批量恢复 ${params.ids.length} 条记录`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量恢复记录失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const markTools: Tool[] = [\n  readMarksTool,\n  createMarkTool,\n  updateMarkTool,\n  deleteMarkTool,\n  restoreMarkTool,\n  searchMarksTool,\n  searchAllMarksTool,\n  createMarksBatchTool,\n  updateMarksBatchTool,\n  deleteMarksBatchTool,\n  restoreMarksBatchTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/memory-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { upsertMemory, getAllMemories, getMemoriesByCategory, deleteMemory, clearAllMemories, Memory } from '@/db/memories'\nimport { fetchEmbedding } from '@/lib/ai/embedding'\n\n/**\n * Tool: List all memories\n */\nexport const listMemoriesTool: Tool = {\n  name: 'list_memories',\n  description: `Query all saved memories (preferences and memory).\n\nUse cases:\n- Before adding a new memory, use this tool to check existing memories\n- Check for conflicting memories (e.g., existing \"answer in Chinese\" vs new \"answer in English\")\n- Get memory IDs for delete operations\n\nReturns memory ID, content, and type (preference/memory).`,\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'category',\n      type: 'string',\n      description: 'Optional: Filter memory type (preference or memory)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      let memories: Memory[]\n      if (params.category) {\n        memories = await getMemoriesByCategory(params.category as 'preference' | 'memory')\n      } else {\n        memories = await getAllMemories()\n      }\n\n      const formatted = memories.map(m =>\n        `ID: ${m.id} [${m.category === 'preference' ? 'Preference' : 'Memory'}] ${m.content}`\n      ).join('\\n')\n\n      return {\n        success: true,\n        message: `Found ${memories.length} memories:\\n${formatted}`,\n      }\n    } catch {\n      return {\n        success: false,\n        error: `Failed to get memory list`,\n      }\n    }\n  },\n}\n\n/**\n * Tool: Delete a specific memory\n */\nexport const deleteMemoryTool: Tool = {\n  name: 'delete_memory',\n  description: `Delete a specific memory.\n\nIMPORTANT: After deletion, you MUST call save_memory to save the new memory. Do not just delete without saving.\n\nUse cases:\n- When replacing a conflicting memory, first delete the old one, then MUST call save_memory to save the new one\n- When user explicitly requests to delete a specific memory\n\nParameters:\n- id: Memory ID (obtained from list_memories result)`,\n  category: 'system',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'id',\n      type: 'string',\n      description: 'Memory ID (from list_memories result)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      await deleteMemory(params.id)\n      return {\n        success: true,\n        message: `Memory deleted`,\n      }\n    } catch {\n      return {\n        success: false,\n        error: `Failed to delete memory`,\n      }\n    }\n  },\n}\n\n/**\n * Tool: Save or update memory\n */\nexport const saveMemoryTool: Tool = {\n  name: 'save_memory',\n  description: `Save or update a memory. MUST call this tool when user says \"remember...\", \"in English\", etc.\n\nIMPORTANT WORKFLOW:\n1. When user wants to remember something, first use list_memories to check existing memories\n2. If conflict found (e.g., existing \"answer in Japanese\", now changing to \"answer in English\"):\n   - First call delete_memory to remove old memory (requires user confirmation)\n   - After deletion completes, MUST call this tool (save_memory) to save the new memory\n3. If no conflict, directly call this tool to save\n\nSupports two types:\n- preference: User preferences like language, format, style - always included in conversations\n- memory: User's facts, experience, expertise - matched intelligently via context\n\nExamples:\n- \"Please answer in English\" -> save as preference\n- \"Remember I'm a React expert\" -> save as memory\n- \"I prefer English\" -> save as preference\n- \"Use Japanese\" -> save as preference`,\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'content',\n      type: 'string',\n      description: 'Content to remember',\n      required: true,\n    },\n    {\n      name: 'category',\n      type: 'string',\n      description: 'Memory type: preference (user settings) or memory (facts/expertise). Auto-detected if not specified',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // Calculate embedding\n      const embedding = await fetchEmbedding(params.content)\n      if (!embedding) {\n        return {\n          success: false,\n          error: 'Cannot generate vector embedding, please check embedding model configuration',\n        }\n      }\n\n      // Save memory\n      const result = await upsertMemory({\n        content: params.content,\n        embedding: JSON.stringify(embedding),\n        category: params.category as 'preference' | 'memory' || undefined,\n      })\n\n      if (result.replaced) {\n        return {\n          success: true,\n          message: `Memory updated (similar memory replaced)`,\n        }\n      }\n\n      return {\n        success: true,\n        message: `Memory saved`,\n      }\n    } catch {\n      return {\n        success: false,\n        error: `Failed to save memory`,\n      }\n    }\n  },\n}\n\n/**\n * Tool: Clear all memories\n */\nexport const clearMemoriesTool: Tool = {\n  name: 'clear_all_memories',\n  description: `Clear all memories.\n\nUse cases:\n- When user explicitly requests to clear all memories\n- Reset all memory data\n\nWARNING: This operation is irreversible, use with caution`,\n  category: 'system',\n  requiresConfirmation: true,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    try {\n      await clearAllMemories()\n      return {\n        success: true,\n        message: `All memories cleared`,\n      }\n    } catch {\n      return {\n        success: false,\n        error: `Failed to clear memories`,\n      }\n    }\n  },\n}\n\nexport const memoryTools: Tool[] = [\n  saveMemoryTool,\n  listMemoriesTool,\n  deleteMemoryTool,\n  clearMemoriesTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/note-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { BaseDirectory, readTextFile, writeTextFile, remove, rename, copyFile } from '@tauri-apps/plugin-fs'\nimport { appDataDir } from '@tauri-apps/api/path'\nimport { getAllMarkdownFiles, MarkdownFile } from '@/lib/files'\nimport { getFilePathOptions, normalizeWorkspaceRelativePath } from '@/lib/workspace'\nimport useArticleStore from '@/stores/article'\nimport useChatStore from '@/stores/chat'\nimport { isLinkedFolder } from '@/lib/files'\nimport emitter from '@/lib/emitter'\n\nfunction normalizeLinkedCandidate(candidate: unknown): string {\n  return typeof candidate === 'string' ? candidate.trim() : ''\n}\n\nfunction getLinkedFileName(path: unknown): string {\n  const normalized = normalizeLinkedCandidate(path)\n  return normalized.split('/').pop() || normalized\n}\n\nfunction matchesLinkedFileCandidate(\n  candidate: unknown,\n  linkedResource: { relativePath?: string; name?: string; path?: string }\n): boolean {\n  const normalized = normalizeLinkedCandidate(candidate)\n  if (!normalized) {\n    return false\n  }\n\n  const linkedPaths = new Set([\n    linkedResource.relativePath,\n    linkedResource.name,\n    linkedResource.path,\n    getLinkedFileName(linkedResource.relativePath),\n    getLinkedFileName(linkedResource.path),\n  ].filter(Boolean))\n\n  return linkedPaths.has(normalized) || linkedPaths.has(getLinkedFileName(normalized))\n}\n\nfunction getBatchLinkedFileReadPlan(\n  filePaths: string[],\n  linkedResource: { relativePath?: string; name?: string; path?: string }\n): { filesToRead: string[]; skippedFiles: string[] } {\n  const filesToRead: string[] = []\n  const skippedFiles: string[] = []\n\n  for (const filePath of filePaths) {\n    if (matchesLinkedFileCandidate(filePath, linkedResource)) {\n      skippedFiles.push(filePath)\n    } else {\n      filesToRead.push(filePath)\n    }\n  }\n\n  return {\n    filesToRead,\n    skippedFiles,\n  }\n}\n\nexport const listMarkdownFilesTool: Tool = {\n  name: 'list_markdown_files',\n  description: 'List all Markdown files in the workspace.',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    try {\n      const files = await getAllMarkdownFiles()\n\n      return {\n        success: true,\n        data: files,\n        message: `找到 ${files.length} 个 Markdown 文件`,\n      }\n    } catch (error) {\n      console.error('[list_markdown_files] 获取文件列表失败', {\n        error: String(error),\n        errorName: error instanceof Error ? error.name : 'unknown',\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `获取 Markdown 文件列表失败: ${error}`,\n      }\n    }\n  },\n}\n\n// ⚠️ DEPRECATED: Use get_editor_content from editor-tools.ts instead\n// This tool reads from disk, but since content is saved in real-time,\n// get_editor_content provides the same result with better performance.\n// @deprecated since content is saved in real-time, use get_editor_content instead\nexport const readMarkdownFileTool: Tool = {\n  name: 'read_markdown_file',\n  description: 'DEPRECATED: Use `get_editor_content` instead. Read content of a Markdown file by path.',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file (relative or absolute path, e.g., \"folder/note.md\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // 检查是否已关联该文件到对话中（避免重复读取）\n      const chatStore = useChatStore.getState()\n      const { linkedResource } = chatStore\n\n      // 如果有关联的文件（非文件夹），且路径匹配，则提示内容已在上下文中\n      if (linkedResource && !isLinkedFolder(linkedResource)) {\n        // 提取文件名进行比较，支持相对路径和绝对路径的匹配\n        const requestedFileName = params.filePath.split('/').pop() || params.filePath\n        const linkedFileName = linkedResource.relativePath.split('/').pop() || linkedResource.relativePath\n\n        if (requestedFileName === linkedFileName) {\n          return {\n            success: true,\n            data: {\n              filePath: params.filePath,\n              content: `[该文件内容已在对话上下文中] 文件 \"${linkedResource.name}\" (${linkedResource.relativePath}) 已关联到当前对话，其完整内容已在上下文中，无需再次读取。请直接使用上下文中已有的文件内容。`,\n              alreadyInContext: true,\n            },\n            message: `文件 \"${linkedResource.name}\" 已在对话上下文中，无需再次读取`,\n          }\n        }\n      }\n\n      let content = ''\n\n      // 统一使用 getFilePathOptions 来处理路径，无论是自定义工作区还是默认工作区\n      const { path, baseDir } = await getFilePathOptions(params.filePath)\n\n      if (baseDir) {\n        content = await readTextFile(path, { baseDir })\n      } else {\n        content = await readTextFile(path)\n      }\n\n      return {\n        success: true,\n        data: { filePath: params.filePath, content },\n        message: `成功读取文件: ${params.filePath}`,\n      }\n    } catch (error) {\n      console.error('[read_markdown_file] 读取失败', {\n        filePath: params.filePath,\n        error: String(error),\n        errorName: error instanceof Error ? error.name : 'unknown',\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `读取文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createFileTool: Tool = {\n  name: 'create_file',\n  description: 'Create a new file in the file system. Returns filePath (relative) and fullPath (absolute for script execution).',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'fileName',\n      type: 'string',\n      description: 'Filename (including extension, e.g., \"note.md\", \"config.json\", \"script.js\")',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'File content (plain text)',\n      required: true,\n    },\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Optional: subfolder path, defaults to root directory. For temporary scripts executed by execute_skill_script, prefer paths like \"skills/pptx/runtime\"',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      let normalizedFolderPath = params.folderPath\n        ? await normalizeWorkspaceRelativePath(params.folderPath)\n        : undefined\n\n      // 验证内容参数\n      if (!params.content || typeof params.content !== 'string') {\n        return {\n          success: false,\n          error: '缺少必需参数 content 或参数类型错误',\n        }\n      }\n\n      // 如果没有提供 fileName，生成默认文件名\n      let fileName = params.fileName\n      if (!fileName || typeof fileName !== 'string' || fileName.trim() === '') {\n        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)\n        fileName = `file-${timestamp}.txt`\n      }\n      fileName = fileName.trim().replace(/\\\\/g, '/')\n\n      if (!normalizedFolderPath && fileName.includes('/')) {\n        const parts = fileName.split('/').filter(Boolean)\n        fileName = parts.pop() || fileName\n        normalizedFolderPath = parts.join('/')\n      }\n\n      let filePath = fileName\n\n      // 如果指定了文件夹路径，拼接路径\n      if (normalizedFolderPath) {\n        filePath = `${normalizedFolderPath}/${fileName}`\n      }\n      const isSpecialSkillPath =\n        filePath.startsWith('skills/') || filePath.startsWith('outputs/')\n\n      // 统一使用 getFilePathOptions 来处理路径\n      const specialArticleRelativePath = isSpecialSkillPath\n        ? `article/${filePath}`.replace(/^article\\/article\\//, 'article/')\n        : undefined\n      const { path, baseDir } = specialArticleRelativePath\n        ? { path: specialArticleRelativePath as string, baseDir: BaseDirectory.AppData }\n        : await getFilePathOptions(filePath)\n\n      // 在创建文件前，确保父目录存在\n      const parentFolderPath = filePath.substring(0, filePath.lastIndexOf('/'))\n      const needsParentFolder = parentFolderPath && parentFolderPath !== filePath\n\n      if (needsParentFolder) {\n        const specialParentRelativePath = isSpecialSkillPath\n          ? `article/${parentFolderPath}`.replace(/^article\\/article\\//, 'article/')\n          : undefined\n        const { path: parentPath, baseDir: parentBaseDir } = specialParentRelativePath\n          ? { path: specialParentRelativePath as string, baseDir: BaseDirectory.AppData }\n          : await getFilePathOptions(parentFolderPath)\n        const { mkdir } = await import('@tauri-apps/plugin-fs')\n        if (parentBaseDir) {\n          await mkdir(parentPath, { baseDir: parentBaseDir, recursive: true })\n        } else {\n          await mkdir(parentPath, { recursive: true })\n        }\n      }\n\n      if (baseDir) {\n        await writeTextFile(path, params.content, { baseDir })\n      } else {\n        await writeTextFile(path, params.content)\n      }\n\n      // 获取完整路径用于返回\n      const { getWorkspacePath } = await import('@/lib/workspace')\n      const workspace = await getWorkspacePath()\n      const workspacePath = workspace.isCustom\n        ? workspace.path\n        : `${await appDataDir()}/article`\n\n      // 构建工作区完整路径\n      const fullPath = `${workspacePath}/${filePath}`\n\n      const articleStore = useArticleStore.getState()\n      const inserted = articleStore.insertLocalEntry(filePath, false)\n      await articleStore.ensurePathExpanded(filePath)\n      if (!inserted) {\n        await articleStore.loadFileTree()\n      }\n\n      // 如果是 Markdown 文件，选中并读取\n      if (filePath.endsWith('.md')) {\n        await articleStore.setActiveFilePath(filePath)\n      }\n\n      return {\n        success: true,\n        data: {\n          filePath,\n          fullPath,\n        },\n        message: `成功创建文件: ${fullPath}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `创建文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateMarkdownFileTool: Tool = {\n  name: 'update_markdown_file',\n  description: 'Update the content of a Markdown note file',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file',\n      required: true,\n    },\n    {\n      name: 'content',\n      type: 'string',\n      description: 'New content (Markdown format)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // 统一使用 getFilePathOptions 来处理路径\n      const { path, baseDir } = await getFilePathOptions(params.filePath)\n\n      if (baseDir) {\n        await writeTextFile(path, params.content, { baseDir })\n      } else {\n        await writeTextFile(path, params.content)\n      }\n\n      // 如果更新的是当前打开的文件，通过 saveCurrentArticle 刷新编辑器内容\n      // 注意：不要使用 setCurrentArticle，因为它会触发 clearStack 清空撤销历史\n      const articleStore = useArticleStore.getState()\n      if (articleStore.activeFilePath === params.filePath) {\n        // 使用 emitter 通知编辑器内容已从外部更新\n        emitter.emit('external-content-update', params.content)\n      }\n\n      return {\n        success: true,\n        message: `成功更新文件: ${params.filePath}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `更新文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteMarkdownFileTool: Tool = {\n  name: 'delete_markdown_file',\n  description: 'Delete a Markdown file from the file system.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const articleStore = useArticleStore.getState()\n      const normalizedFilePath = await normalizeWorkspaceRelativePath(params.filePath)\n\n      // 检查是否是当前打开的文件\n      const isCurrentFile = articleStore.activeFilePath === normalizedFilePath\n\n      // 统一使用 getFilePathOptions 来处理路径\n      const { path, baseDir } = await getFilePathOptions(normalizedFilePath)\n\n      if (baseDir) {\n        await remove(path, { baseDir })\n      } else {\n        await remove(path)\n      }\n\n      // 删除向量数据库中的记录\n      const filename = normalizedFilePath.split('/').pop() || normalizedFilePath\n      try {\n        const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n        await deleteVectorDocumentsByFilename(filename)\n      } catch (error) {\n        console.error(`删除文件 ${filename} 的向量数据失败:`, error)\n      }\n\n      const removed = articleStore.removeLocalEntry(normalizedFilePath)\n      if (!removed) {\n        await articleStore.loadFileTree()\n      }\n\n      await articleStore.cleanTabsByDeletedFile(normalizedFilePath)\n\n      // 如果删除的是当前打开的文件，取消选择并清空内容\n      if (isCurrentFile) {\n        await articleStore.setActiveFilePath('')\n        articleStore.setCurrentArticle('')\n      }\n\n      return {\n        success: true,\n        message: `成功删除文件: ${normalizedFilePath}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `删除文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const searchMarkdownFilesTool: Tool = {\n  name: 'search_markdown_files',\n  description: `Search content within Markdown files in the file system.\n\n**IMPORTANT - Only use when user EXPLICITLY requests search**:\n- ✅ CORRECT: User says \"搜索关于React的笔记\" / \"查找包含xxx的内容\" / \"帮我找找\"\n- ❌ WRONG: User asks a question without explicitly asking to search (e.g., \"What is React?\" without asking to search)\n\nTwo modes:\n- keyword (default): Fast exact matching for specific terms like \"useState\", \"React\", \"API\"\n- rag: Semantic search - ONLY use when user explicitly asks for semantic/AI search (e.g., \"语义搜索\" / \"AI搜索\" / \"相关笔记\")\n\nUse folderPath to limit scope to a specific folder.`,\n  category: 'search',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'query',\n      type: 'string',\n      description: 'Search keyword or natural language query',\n      required: true,\n    },\n    {\n      name: 'mode',\n      type: 'string',\n      description: 'Search mode: keyword (default, keyword matching) or rag (semantic search)',\n      required: false,\n    },\n    {\n      name: 'folderPath',\n      type: 'string',\n      description: 'Optional: limit search to specified folder (relative path)',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      // RAG 模式：调用 RAG 搜索\n      if (params.mode === 'rag') {\n        const { getContextForQuery, getContextForQueryInFolder } = await import('@/lib/rag')\n\n        // 将查询转换为关键词格式\n        const keywords = [{ text: params.query, weight: 1 }]\n\n        // 根据是否指定文件夹选择不同的 RAG 方法\n        const ragResult = params.folderPath\n          ? await getContextForQueryInFolder(keywords, params.folderPath)\n          : await getContextForQuery(keywords)\n\n        // 获取所有文件列表，用于补全路径（向量数据库只存文件名，需要补全相对路径）\n        const allFiles = await getAllMarkdownFiles()\n        // 创建文件名到相对路径的映射（处理同名文件）\n        const fileNameToPath = new Map<string, string[]>()\n        for (const file of allFiles) {\n          const name = file.name\n          if (!fileNameToPath.has(name)) {\n            fileNameToPath.set(name, [])\n          }\n          fileNameToPath.get(name)!.push(file.relativePath)\n        }\n\n        // 格式化返回结果，补全路径\n        const formattedResults = ragResult.sourceDetails.map(source => {\n          // 向量搜索返回的 filepath 可能只是文件名，需要补全路径\n          let filePath = source.filepath\n          if (!filePath.includes('/')) {\n            // filepath 只是文件名，从映射中获取完整路径\n            const paths = fileNameToPath.get(source.filename)\n            if (paths && paths.length > 0) {\n              // 如果有多个同名文件，使用第一个\n              filePath = paths[0]\n            }\n          }\n          return {\n            filePath,\n            fileName: source.filename,\n            matchedContent: source.content,\n          }\n        })\n\n        return {\n          success: true,\n          data: formattedResults,\n          message: `RAG 搜索找到 ${ragResult.sources.length} 个相关笔记${params.folderPath ? `（文件夹：${params.folderPath}）` : ''}`,\n        }\n      }\n\n      // 关键词模式：原有的精确匹配搜索\n      // 如果指定了文件夹路径，先过滤文件列表\n      let allFiles = await getAllMarkdownFiles()\n      if (params.folderPath) {\n        allFiles = allFiles.filter(file => file.relativePath.startsWith(params.folderPath))\n      }\n\n      const results: Array<{\n        filePath: string\n        fileName: string\n        matchedContent: string\n        lineNumber?: number\n      }> = []\n\n      for (const file of allFiles) {\n        try {\n          let content = ''\n\n          // 统一使用 getFilePathOptions 来处理路径\n          const { path, baseDir } = await getFilePathOptions(file.relativePath)\n\n          if (baseDir) {\n            content = await readTextFile(path, { baseDir })\n          } else {\n            content = await readTextFile(path)\n          }\n\n          if (content.toLowerCase().includes(params.query.toLowerCase())) {\n            // 按行分割内容\n            const lines = content.split('\\n')\n\n            // 查找匹配的行\n            for (let i = 0; i < lines.length; i++) {\n              if (lines[i].toLowerCase().includes(params.query.toLowerCase())) {\n                // 提取上下文（前后各 2 行）\n                const contextStart = Math.max(0, i - 2)\n                const contextEnd = Math.min(lines.length, i + 3)\n                const contextLines = lines.slice(contextStart, contextEnd)\n\n                // 格式化匹配内容，包含行号\n                const formattedLines = contextLines.map((line, idx) => {\n                  const actualLineNum = contextStart + idx + 1\n                  const isMatchLine = actualLineNum === i + 1\n                  const prefix = isMatchLine ? '>' : ' '\n                  return `${prefix} ${actualLineNum}: ${line}`\n                })\n\n                results.push({\n                  filePath: file.relativePath,\n                  fileName: file.name,\n                  matchedContent: formattedLines.join('\\n'),\n                  lineNumber: i + 1,\n                })\n\n                break // 只添加第一个匹配位置，避免重复\n              }\n            }\n          }\n        } catch (error) {\n          console.error(`读取文件 ${file.path} 失败:`, error)\n        }\n      }\n\n      return {\n        success: true,\n        data: results,\n        message: `找到 ${results.length} 个匹配的文件${params.folderPath ? `（文件夹：${params.folderPath}）` : ''}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `搜索文件失败: ${error}`,\n      }\n    }\n  },\n}\n\n// ⚠️ DEPRECATED: Use replace_editor_content from editor-tools.ts instead\n// This tool writes to disk, but since content is saved in real-time,\n// replace_editor_content provides the same result with better performance.\n// @deprecated since content is saved in real-time, use replace_editor_content instead\nexport const modifyCurrentNoteTool: Tool = {\n  name: 'modify_current_note',\n  description: '**DEPRECATED**: Use replace_editor_content from editor-tools instead. This tool writes to disk, but replace_editor_content provides better performance for real-time saved content.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    return {\n      success: false,\n      error: 'This tool is deprecated. Use replace_editor_content from editor-tools instead.',\n    }\n  },\n}\n\nexport const readMarkdownFilesBatchTool: Tool = {\n  name: 'read_markdown_files_batch',\n  description: 'Batch read multiple Markdown note file contents to avoid loop calls. Use for scenarios requiring multiple files to be read at once.',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'filePaths',\n      type: 'array',\n      description: 'Array of Markdown file paths',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.filePaths) || params.filePaths.length === 0) {\n        return {\n          success: false,\n          error: '参数 filePaths 必须是非空数组',\n        }\n      }\n\n      const results = []\n      const errors = []\n      const skipped = []\n      const { linkedResource } = useChatStore.getState()\n      const readPlan = linkedResource && !isLinkedFolder(linkedResource)\n        ? getBatchLinkedFileReadPlan(params.filePaths, linkedResource)\n        : { filesToRead: params.filePaths, skippedFiles: [] }\n\n      for (const filePath of readPlan.skippedFiles) {\n        skipped.push({\n          filePath,\n          alreadyInContext: true,\n        })\n      }\n\n      for (const filePath of readPlan.filesToRead) {\n        try {\n          let content = ''\n\n          // 统一使用 getFilePathOptions 来处理路径\n          const { path, baseDir } = await getFilePathOptions(filePath)\n\n          if (baseDir) {\n            content = await readTextFile(path, { baseDir })\n          } else {\n            content = await readTextFile(path)\n          }\n\n          results.push({ filePath, content })\n        } catch (error) {\n          errors.push({ filePath, error: String(error) })\n        }\n      }\n\n      // 只要有任何文件读取失败，就标记为失败状态\n      const hasErrors = errors.length > 0\n      return {\n        success: !hasErrors,\n        data: {\n          files: results,\n          skipped,\n          failed: errors,\n          successCount: results.length,\n          skippedCount: skipped.length,\n          failCount: errors.length,\n        },\n        message: hasErrors\n          ? `部分失败：成功读取 ${results.length} 个文件，跳过 ${skipped.length} 个已在上下文中的文件，${errors.length} 个失败`\n          : `成功读取 ${results.length} 个文件，跳过 ${skipped.length} 个已在上下文中的文件`,\n        error: hasErrors\n          ? `部分文件读取失败：${errors.map(e => `${e.filePath}: ${e.error}`).join('; ')}`\n          : undefined,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量读取文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteMarkdownFilesBatchTool: Tool = {\n  name: 'delete_markdown_files_batch',\n  description: 'Batch delete multiple Markdown note files to avoid loop calls.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePaths',\n      type: 'array',\n      description: 'Array of Markdown file paths to delete',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.filePaths) || params.filePaths.length === 0) {\n        return {\n          success: false,\n          error: '参数 filePaths 必须是非空数组',\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      const results = []\n      const errors = []\n      let currentFileDeleted = false\n\n      for (const filePath of params.filePaths) {\n        try {\n          if (articleStore.activeFilePath === filePath) {\n            currentFileDeleted = true\n          }\n\n          // 统一使用 getFilePathOptions 来处理路径\n          const { path, baseDir } = await getFilePathOptions(filePath)\n\n          if (baseDir) {\n            await remove(path, { baseDir })\n          } else {\n            await remove(path)\n          }\n\n          results.push(filePath)\n        } catch (error) {\n          errors.push({ filePath, error: String(error) })\n        }\n      }\n\n      // 批量删除向量数据库中的记录（只删除成功的文件）\n      const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n      for (const filePath of results) {\n        const filename = filePath.split('/').pop() || filePath\n        try {\n          await deleteVectorDocumentsByFilename(filename)\n        } catch (error) {\n          console.error(`删除文件 ${filename} 的向量数据失败:`, error)\n        }\n      }\n\n      await articleStore.loadFileTree()\n\n      if (currentFileDeleted) {\n        await articleStore.setActiveFilePath('')\n        articleStore.setCurrentArticle('')\n      }\n\n      // 只要有任何文件删除失败，就标记为失败状态\n      const hasErrors = errors.length > 0\n      return {\n        success: !hasErrors,\n        data: {\n          deleted: results,\n          failed: errors,\n          successCount: results.length,\n          failCount: errors.length,\n        },\n        message: hasErrors\n          ? `部分失败：成功删除 ${results.length} 个文件，${errors.length} 个失败`\n          : `成功删除 ${results.length} 个文件`,\n        error: hasErrors\n          ? `部分文件删除失败：${errors.map(e => `${e.filePath}: ${e.error}`).join('; ')}`\n          : undefined,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量删除文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const listMarkdownFilesByDateTool: Tool = {\n  name: 'list_markdown_files_by_date',\n  description: 'List Markdown note files updated within a specified time range. Supports filtering by relative time (e.g., last N days, N days ago) or absolute time range.',\n  category: 'note',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'lastNDays',\n      type: 'number',\n      description: 'Optional: get files modified within the last N days. Mutually exclusive with olderThanDays/startDate/endDate, has highest priority.',\n      required: false,\n    },\n    {\n      name: 'olderThanDays',\n      type: 'number',\n      description: 'Optional: get files modified more than N days ago (excluding recent N days). Mutually exclusive with lastNDays/startDate/endDate.',\n      required: false,\n    },\n    {\n      name: 'startDate',\n      type: 'string',\n      description: 'Optional: start date (ISO 8601 format, e.g., 2024-01-01 or 2024-01-01T00:00:00Z)',\n      required: false,\n    },\n    {\n      name: 'endDate',\n      type: 'string',\n      description: 'Optional: end date (ISO 8601 format, e.g., 2024-12-31 or 2024-12-31T23:59:59Z), defaults to current time',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      let startDate: Date | undefined\n      let endDate: Date | undefined\n\n      // 优先使用 lastNDays 参数（最近 N 天）\n      if (params.lastNDays && typeof params.lastNDays === 'number') {\n        const now = new Date()\n        startDate = new Date(now.getTime() - params.lastNDays * 24 * 60 * 60 * 1000)\n        endDate = now\n      }\n      // 其次使用 olderThanDays 参数（N 天之前）\n      else if (params.olderThanDays && typeof params.olderThanDays === 'number') {\n        const now = new Date()\n        endDate = new Date(now.getTime() - params.olderThanDays * 24 * 60 * 60 * 1000)\n        // startDate 不设置，表示从最早开始到 endDate\n      }\n      // 最后使用 startDate/ endDate 参数（绝对时间范围）\n      else {\n        if (params.startDate) {\n          startDate = new Date(params.startDate)\n          if (isNaN(startDate.getTime())) {\n            return {\n              success: false,\n              error: `无效的 startDate 格式: ${params.startDate}，请使用 ISO 8601 格式（如 2024-01-01）`,\n            }\n          }\n        }\n        if (params.endDate) {\n          endDate = new Date(params.endDate)\n          if (isNaN(endDate.getTime())) {\n            return {\n              success: false,\n              error: `无效的 endDate 格式: ${params.endDate}，请使用 ISO 8601 格式（如 2024-12-31）`,\n            }\n          }\n        } else {\n          endDate = new Date()\n        }\n      }\n\n      // 获取包含元数据的文件列表\n      const allFiles = await getAllMarkdownFiles(true)\n\n      // 根据时间范围过滤\n      const filteredFiles: MarkdownFile[] = []\n      for (const file of allFiles) {\n        if (!file.modifiedAt) {\n          continue // 没有修改时间的文件跳过\n        }\n\n        const modifiedTime = new Date(file.modifiedAt)\n\n        // 检查是否在时间范围内\n        if (startDate && modifiedTime < startDate) {\n          continue\n        }\n        if (endDate && modifiedTime > endDate) {\n          continue\n        }\n\n        filteredFiles.push(file)\n      }\n\n      // 按修改时间倒序排列\n      filteredFiles.sort((a, b) => {\n        const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0\n        const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0\n        return bTime - aTime\n      })\n\n      return {\n        success: true,\n        data: filteredFiles.map(({ name, relativePath, modifiedAt, metadata }) => ({\n          name,\n          relativePath,\n          modifiedAt: modifiedAt?.toISOString(),\n          size: metadata?.size,\n          createdAt: metadata?.createdAt?.toISOString(),\n          accessedAt: metadata?.accessedAt?.toISOString(),\n          isReadOnly: metadata?.isReadOnly,\n        })),\n        message: `找到 ${filteredFiles.length} 个符合条件的文件（${startDate ? `从 ${startDate.toISOString()}` : ''}${endDate ? `到 ${endDate.toISOString()}` : ''}）`,\n      }\n    } catch (error) {\n      console.error('[list_markdown_files_by_date] 获取文件列表失败', {\n        error: String(error),\n        errorName: error instanceof Error ? error.name : 'unknown',\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `按时间获取 Markdown 文件列表失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const renameFileTool: Tool = {\n  name: 'rename_file',\n  description: 'Rename the specified Markdown file. Only changes the filename, not the folder containing the file.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file to rename',\n      required: true,\n    },\n    {\n      name: 'newName',\n      type: 'string',\n      description: 'New filename (including .md extension, e.g., \"new-note.md\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const articleStore = useArticleStore.getState()\n      const normalizedFilePath = await normalizeWorkspaceRelativePath(params.filePath)\n\n      // 检查是否是当前打开的文件\n      const isCurrentFile = articleStore.activeFilePath === normalizedFilePath\n\n      // 验证新文件名以 .md 结尾\n      let newName = params.newName\n      if (!newName.endsWith('.md')) {\n        newName += '.md'\n      }\n\n      // 获取原文件的完整路径信息\n      const { path: oldPath, baseDir } = await getFilePathOptions(normalizedFilePath)\n\n      // 构建新路径（保持原文件夹，只改文件名）\n      const pathParts = normalizedFilePath.split('/')\n      pathParts[pathParts.length - 1] = newName\n      const newRelativePath = pathParts.join('/')\n\n      const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n      // 检查新文件名是否已存在\n      const { exists } = await import('@tauri-apps/plugin-fs')\n      const targetExists = newBaseDir\n        ? await exists(newPath, { baseDir: newBaseDir })\n        : await exists(newPath)\n\n      if (targetExists) {\n        return {\n          success: false,\n          error: `文件名 \"${newName}\" 已存在，请使用其他文件名`,\n        }\n      }\n\n      // 执行重命名\n      if (baseDir) {\n        await rename(oldPath, newPath, { oldPathBaseDir: baseDir, newPathBaseDir: baseDir })\n      } else {\n        await rename(oldPath, newPath)\n      }\n\n      const moved = articleStore.moveLocalEntry(normalizedFilePath, newRelativePath)\n      await articleStore.ensurePathExpanded(newRelativePath)\n      if (!moved) {\n        await articleStore.loadFileTree()\n      }\n\n      await articleStore.syncOpenTabsForPathChange(normalizedFilePath, newRelativePath)\n\n      // 如果重命名的是当前打开的文件，更新 activeFilePath 并重新读取内容\n      if (isCurrentFile) {\n        await articleStore.setActiveFilePath(newRelativePath)\n      }\n\n      return {\n        success: true,\n        data: {\n          oldPath: normalizedFilePath,\n          newPath: newRelativePath,\n          newName,\n        },\n        message: `成功将 \"${normalizedFilePath}\" 重命名为 \"${newRelativePath}\"`,\n      }\n    } catch (error) {\n      console.error('[rename_file] 重命名失败', {\n        filePath: params.filePath,\n        newName: params.newName,\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `重命名文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const moveFileTool: Tool = {\n  name: 'move_file',\n  description: 'Move the specified Markdown file to another folder. The filename remains unchanged.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file to move',\n      required: true,\n    },\n    {\n      name: 'targetFolderPath',\n      type: 'string',\n      description: 'Target folder path (relative to notes root directory, e.g., \"frontend/React\" or \"study-notes\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const articleStore = useArticleStore.getState()\n      const normalizedFilePath = await normalizeWorkspaceRelativePath(params.filePath)\n      const normalizedTargetFolderPath = await normalizeWorkspaceRelativePath(params.targetFolderPath)\n\n      // 检查是否是当前打开的文件\n      const isCurrentFile = articleStore.activeFilePath === normalizedFilePath\n\n      // 提取原文件名\n      const fileName = normalizedFilePath.split('/').pop() || normalizedFilePath\n\n      // 构建新路径\n      const newRelativePath = normalizedTargetFolderPath\n        ? `${normalizedTargetFolderPath}/${fileName}`\n        : fileName\n\n      // 验证目标文件夹是否存在\n      const { exists } = await import('@tauri-apps/plugin-fs')\n      const { path: targetFolderDir, baseDir: targetBaseDir } = await getFilePathOptions(normalizedTargetFolderPath)\n\n      const targetFolderExists = targetBaseDir\n        ? await exists(targetFolderDir, { baseDir: targetBaseDir })\n        : await exists(targetFolderDir)\n\n      if (!targetFolderExists) {\n        return {\n          success: false,\n          error: `目标文件夹 \"${normalizedTargetFolderPath}\" 不存在，请先创建该文件夹`,\n        }\n      }\n\n      // 获取原文件和新文件的完整路径信息\n      const { path: oldPath, baseDir: oldBaseDir } = await getFilePathOptions(normalizedFilePath)\n      const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n      // 检查目标位置是否已存在同名文件\n      const targetExists = newBaseDir\n        ? await exists(newPath, { baseDir: newBaseDir })\n        : await exists(newPath)\n\n      if (targetExists) {\n        return {\n          success: false,\n          error: `目标位置已存在同名文件 \"${fileName}\"，请先重命名或删除该文件`,\n        }\n      }\n\n      // 执行移动（使用 rename）\n      if (oldBaseDir) {\n        await rename(oldPath, newPath, { oldPathBaseDir: oldBaseDir, newPathBaseDir: oldBaseDir })\n      } else {\n        await rename(oldPath, newPath)\n      }\n\n      const moved = articleStore.moveLocalEntry(normalizedFilePath, newRelativePath)\n      await articleStore.ensurePathExpanded(newRelativePath)\n      if (!moved) {\n        await articleStore.loadFileTree()\n      }\n\n      await articleStore.syncOpenTabsForPathChange(normalizedFilePath, newRelativePath)\n\n      // 如果移动的是当前打开的文件，更新 activeFilePath 并重新读取内容\n      if (isCurrentFile) {\n        await articleStore.setActiveFilePath(newRelativePath)\n      }\n\n      return {\n        success: true,\n        data: {\n          oldPath: normalizedFilePath,\n          newPath: newRelativePath,\n        },\n        message: `成功将 \"${normalizedFilePath}\" 移动到 \"${newRelativePath}\"`,\n      }\n    } catch (error) {\n      console.error('[move_file] 移动失败', {\n        filePath: params.filePath,\n        targetFolderPath: params.targetFolderPath,\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `移动文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const copyFileTool: Tool = {\n  name: 'copy_file',\n  description: 'Copy the specified Markdown file to another folder. The original file remains unchanged.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'filePath',\n      type: 'string',\n      description: 'Path of the Markdown file to copy',\n      required: true,\n    },\n    {\n      name: 'targetFolderPath',\n      type: 'string',\n      description: 'Target folder path (relative to notes root directory, e.g., \"frontend/React\" or \"study-notes\"). Leave empty to copy to current folder',\n      required: false,\n    },\n    {\n      name: 'newName',\n      type: 'string',\n      description: 'Optional: new filename (including .md extension). If not specified, uses the original filename, and automatically adds a number if a file with the same name exists',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const articleStore = useArticleStore.getState()\n      const normalizedFilePath = await normalizeWorkspaceRelativePath(params.filePath)\n      const normalizedTargetFolderPath = params.targetFolderPath\n        ? await normalizeWorkspaceRelativePath(params.targetFolderPath)\n        : undefined\n\n      // 提取原文件名\n      const originalFileName = normalizedFilePath.split('/').pop() || normalizedFilePath\n\n      // 确定新文件名\n      let newFileName = params.newName || originalFileName\n      if (!newFileName.endsWith('.md')) {\n        newFileName += '.md'\n      }\n\n      // 构建新路径\n      let newRelativePath = normalizedTargetFolderPath\n        ? `${normalizedTargetFolderPath}/${newFileName}`\n        : newFileName\n\n      // 验证目标文件夹是否存在（如果指定了目标文件夹）\n      if (normalizedTargetFolderPath) {\n        const { exists } = await import('@tauri-apps/plugin-fs')\n        const { path: targetFolderDir, baseDir: targetBaseDir } = await getFilePathOptions(normalizedTargetFolderPath)\n\n        const targetFolderExists = targetBaseDir\n          ? await exists(targetFolderDir, { baseDir: targetBaseDir })\n          : await exists(targetFolderDir)\n\n        if (!targetFolderExists) {\n          return {\n            success: false,\n            error: `目标文件夹 \"${normalizedTargetFolderPath}\" 不存在，请先创建该文件夹`,\n          }\n        }\n      }\n\n      // 获取原文件和新文件的完整路径信息\n      const { path: oldPath, baseDir: oldBaseDir } = await getFilePathOptions(normalizedFilePath)\n      const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n      // 检查目标位置是否已存在同名文件\n      const { exists } = await import('@tauri-apps/plugin-fs')\n      let targetExists = newBaseDir\n        ? await exists(newPath, { baseDir: newBaseDir })\n        : await exists(newPath)\n\n      // 如果存在同名文件且没有指定新文件名，自动添加序号\n      if (targetExists && !params.newName) {\n        const baseName = newFileName.replace(/\\.md$/, '')\n        let counter = 1\n        do {\n          newFileName = `${baseName} ${counter}.md`\n          newRelativePath = normalizedTargetFolderPath\n            ? `${normalizedTargetFolderPath}/${newFileName}`\n            : newFileName\n\n          const { path: checkPath, baseDir: checkBaseDir } = await getFilePathOptions(newRelativePath)\n          targetExists = checkBaseDir\n            ? await exists(checkPath, { baseDir: checkBaseDir })\n            : await exists(checkPath)\n          counter++\n        } while (targetExists && counter < 1000)\n      }\n\n      // 重新获取最终的新路径\n      const { path: finalNewPath, baseDir: finalNewBaseDir } = await getFilePathOptions(newRelativePath)\n\n      // 执行复制\n      if (oldBaseDir && finalNewBaseDir) {\n        await copyFile(oldPath, finalNewPath, { fromPathBaseDir: oldBaseDir, toPathBaseDir: finalNewBaseDir })\n      } else {\n        await copyFile(oldPath, finalNewPath)\n      }\n\n      const inserted = articleStore.insertLocalEntry(newRelativePath, false)\n      await articleStore.ensurePathExpanded(newRelativePath)\n      if (!inserted) {\n        await articleStore.loadFileTree()\n      }\n\n      return {\n        success: true,\n        data: {\n          sourcePath: normalizedFilePath,\n          newPath: newRelativePath,\n          newName: newFileName,\n        },\n        message: `成功将 \"${normalizedFilePath}\" 复制为 \"${newRelativePath}\"`,\n      }\n    } catch (error) {\n      console.error('[copy_file] 复制失败', {\n        filePath: params.filePath,\n        targetFolderPath: params.targetFolderPath,\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `复制文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const moveFilesBatchTool: Tool = {\n  name: 'move_files_batch',\n  description: 'Batch move multiple Markdown files to another folder to avoid loop calls. The filenames remain unchanged.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'files',\n      type: 'array',\n      description: 'Array of files to move, each file contains filePath (source path) and targetFolderPath (destination folder)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.files) || params.files.length === 0) {\n        return {\n          success: false,\n          error: '参数 files 必须是非空数组',\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      const results = []\n      const errors = []\n      let currentFileMoved = false\n\n      for (const file of params.files) {\n        try {\n          const filePath = file.filePath\n          const targetFolderPath = file.targetFolderPath\n\n          // 检查是否是当前打开的文件\n          if (articleStore.activeFilePath === filePath) {\n            currentFileMoved = true\n          }\n\n          // 提取原文件名\n          const fileName = filePath.split('/').pop() || filePath\n\n          // 构建新路径\n          const newRelativePath = targetFolderPath\n            ? `${targetFolderPath}/${fileName}`\n            : fileName\n\n          // 验证目标文件夹是否存在\n          const { exists } = await import('@tauri-apps/plugin-fs')\n          const { path: targetFolderDir, baseDir: targetBaseDir } = await getFilePathOptions(targetFolderPath)\n\n          const targetFolderExists = targetBaseDir\n            ? await exists(targetFolderDir, { baseDir: targetBaseDir })\n            : await exists(targetFolderDir)\n\n          if (!targetFolderExists) {\n            errors.push({ filePath, error: `目标文件夹 \"${targetFolderPath}\" 不存在` })\n            continue\n          }\n\n          // 获取原文件和新文件的完整路径信息\n          const { path: oldPath, baseDir: oldBaseDir } = await getFilePathOptions(filePath)\n          const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n          // 检查目标位置是否已存在同名文件\n          const targetExists = newBaseDir\n            ? await exists(newPath, { baseDir: newBaseDir })\n            : await exists(newPath)\n\n          if (targetExists) {\n            errors.push({ filePath, error: '目标位置已存在同名文件' })\n            continue\n          }\n\n          // 执行移动（使用 rename）\n          if (oldBaseDir) {\n            await rename(oldPath, newPath, { oldPathBaseDir: oldBaseDir, newPathBaseDir: oldBaseDir })\n          } else {\n            await rename(oldPath, newPath)\n          }\n\n          results.push({ oldPath: filePath, newPath: newRelativePath })\n        } catch (error) {\n          errors.push({ filePath: file.filePath, error: String(error) })\n        }\n      }\n\n      // 刷新文件列表\n      await articleStore.loadFileTree()\n\n      // 如果移动了当前打开的文件，需要更新 activeFilePath\n      if (currentFileMoved && results.length > 0) {\n        const movedFile = results.find(r => articleStore.activeFilePath === r.oldPath)\n        if (movedFile) {\n          await articleStore.setActiveFilePath(movedFile.newPath)\n          await articleStore.readArticle(movedFile.newPath)\n        }\n      }\n\n      // 只要有任何文件移动失败，就标记为失败状态\n      return {\n        success: errors.length === 0,\n        data: {\n          moved: results,\n          failed: errors,\n          successCount: results.length,\n          failCount: errors.length,\n        },\n        message: errors.length === 0\n          ? `成功移动 ${results.length} 个文件`\n          : `部分失败：成功移动 ${results.length} 个文件，${errors.length} 个失败`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量移动文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const copyFilesBatchTool: Tool = {\n  name: 'copy_files_batch',\n  description: 'Batch copy multiple Markdown files to other folders to avoid loop calls. The original files remain unchanged.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'files',\n      type: 'array',\n      description: 'Array of files to copy, each file contains filePath (source path), targetFolderPath (destination folder), and optionally newName (new filename)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.files) || params.files.length === 0) {\n        return {\n          success: false,\n          error: '参数 files 必须是非空数组',\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      const results = []\n      const errors = []\n\n      for (const file of params.files) {\n        try {\n          const filePath = file.filePath\n          const targetFolderPath = file.targetFolderPath\n          const newName = file.newName\n\n          // 提取原文件名\n          const originalFileName = filePath.split('/').pop() || filePath\n\n          // 确定新文件名\n          let newFileName = newName || originalFileName\n          if (!newFileName.endsWith('.md')) {\n            newFileName += '.md'\n          }\n\n          // 构建新路径\n          let newRelativePath = targetFolderPath\n            ? `${targetFolderPath}/${newFileName}`\n            : newFileName\n\n          // 验证目标文件夹是否存在（如果指定了目标文件夹）\n          if (targetFolderPath) {\n            const { exists } = await import('@tauri-apps/plugin-fs')\n            const { path: targetFolderDir, baseDir: targetBaseDir } = await getFilePathOptions(targetFolderPath)\n\n            const targetFolderExists = targetBaseDir\n              ? await exists(targetFolderDir, { baseDir: targetBaseDir })\n              : await exists(targetFolderDir)\n\n            if (!targetFolderExists) {\n              errors.push({ filePath, error: `目标文件夹 \"${targetFolderPath}\" 不存在` })\n              continue\n            }\n          }\n\n          // 获取原文件和新文件的完整路径信息\n          const { path: oldPath, baseDir: oldBaseDir } = await getFilePathOptions(filePath)\n          const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n          // 检查目标位置是否已存在同名文件\n          const { exists } = await import('@tauri-apps/plugin-fs')\n          let targetExists = newBaseDir\n            ? await exists(newPath, { baseDir: newBaseDir })\n            : await exists(newPath)\n\n          // 如果存在同名文件且没有指定新文件名，自动添加序号\n          if (targetExists && !newName) {\n            const baseName = newFileName.replace(/\\.md$/, '')\n            let counter = 1\n            do {\n              newFileName = `${baseName} ${counter}.md`\n              newRelativePath = targetFolderPath\n                ? `${targetFolderPath}/${newFileName}`\n                : newFileName\n\n              const { path: checkPath, baseDir: checkBaseDir } = await getFilePathOptions(newRelativePath)\n              targetExists = checkBaseDir\n                ? await exists(checkPath, { baseDir: checkBaseDir })\n                : await exists(checkPath)\n              counter++\n            } while (targetExists && counter < 1000)\n          }\n\n          // 重新获取最终的新路径\n          const { path: finalNewPath, baseDir: finalNewBaseDir } = await getFilePathOptions(newRelativePath)\n\n          // 执行复制\n          if (oldBaseDir && finalNewBaseDir) {\n            await copyFile(oldPath, finalNewPath, { fromPathBaseDir: oldBaseDir, toPathBaseDir: finalNewBaseDir })\n          } else {\n            await copyFile(oldPath, finalNewPath)\n          }\n\n          results.push({\n            sourcePath: filePath,\n            newPath: newRelativePath,\n            newName: newFileName,\n          })\n        } catch (error) {\n          errors.push({ filePath: file.filePath, error: String(error) })\n        }\n      }\n\n      // 刷新文件列表\n      await articleStore.loadFileTree()\n\n      // 只要有任何文件复制失败，就标记为失败状态\n      return {\n        success: errors.length === 0,\n        data: {\n          copied: results,\n          failed: errors,\n          successCount: results.length,\n          failCount: errors.length,\n        },\n        message: errors.length === 0\n          ? `成功复制 ${results.length} 个文件`\n          : `部分失败：成功复制 ${results.length} 个文件，${errors.length} 个失败`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量复制文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const renameFilesBatchTool: Tool = {\n  name: 'rename_files_batch',\n  description: 'Batch rename multiple Markdown files to avoid loop calls. Only changes the filenames, not the folders containing the files.',\n  category: 'note',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'files',\n      type: 'array',\n      description: 'Array of files to rename, each file contains filePath (original path) and newName (new filename including .md extension)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.files) || params.files.length === 0) {\n        return {\n          success: false,\n          error: '参数 files 必须是非空数组',\n        }\n      }\n\n      const articleStore = useArticleStore.getState()\n      const results = []\n      const errors = []\n      let currentFileRenamed = false\n\n      for (const file of params.files) {\n        try {\n          const filePath = file.filePath\n          let newName = file.newName\n\n          // 验证新文件名以 .md 结尾\n          if (!newName.endsWith('.md')) {\n            newName += '.md'\n          }\n\n          // 检查是否是当前打开的文件\n          if (articleStore.activeFilePath === filePath) {\n            currentFileRenamed = true\n          }\n\n          // 获取原文件的完整路径信息\n          const { path: oldPath, baseDir } = await getFilePathOptions(filePath)\n\n          // 构建新路径（保持原文件夹，只改文件名）\n          const pathParts = filePath.split('/')\n          pathParts[pathParts.length - 1] = newName\n          const newRelativePath = pathParts.join('/')\n\n          const { path: newPath, baseDir: newBaseDir } = await getFilePathOptions(newRelativePath)\n\n          // 检查新文件名是否已存在\n          const { exists } = await import('@tauri-apps/plugin-fs')\n          const targetExists = newBaseDir\n            ? await exists(newPath, { baseDir: newBaseDir })\n            : await exists(newPath)\n\n          if (targetExists) {\n            errors.push({ filePath, error: `文件名 \"${newName}\" 已存在` })\n            continue\n          }\n\n          // 执行重命名\n          if (baseDir) {\n            await rename(oldPath, newPath, { oldPathBaseDir: baseDir, newPathBaseDir: baseDir })\n          } else {\n            await rename(oldPath, newPath)\n          }\n\n          results.push({\n            oldPath: filePath,\n            newPath: newRelativePath,\n            newName,\n          })\n        } catch (error) {\n          errors.push({ filePath: file.filePath, error: String(error) })\n        }\n      }\n\n      // 刷新文件列表\n      await articleStore.loadFileTree()\n\n      // 如果重命名了当前打开的文件，更新 activeFilePath 并重新读取内容\n      if (currentFileRenamed && results.length > 0) {\n        const renamedFile = results.find(r => articleStore.activeFilePath === r.oldPath)\n        if (renamedFile) {\n          await articleStore.setActiveFilePath(renamedFile.newPath)\n          await articleStore.readArticle(renamedFile.newPath)\n        }\n      }\n\n      // 只要有任何文件重命名失败，就标记为失败状态\n      return {\n        success: errors.length === 0,\n        data: {\n          renamed: results,\n          failed: errors,\n          successCount: results.length,\n          failCount: errors.length,\n        },\n        message: errors.length === 0\n          ? `成功重命名 ${results.length} 个文件`\n          : `部分失败：成功重命名 ${results.length} 个文件，${errors.length} 个失败`,\n      }\n    } catch (error) {\n      console.error('[rename_files_batch] 批量重命名失败', {\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `批量重命名文件失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const noteTools: Tool[] = [\n  listMarkdownFilesTool,\n  readMarkdownFileTool,\n  createFileTool,\n  deleteMarkdownFileTool,\n  searchMarkdownFilesTool,\n  // modifyCurrentNoteTool: DEPRECATED - use replace_editor_content from editor-tools.ts instead\n  readMarkdownFilesBatchTool,\n  deleteMarkdownFilesBatchTool,\n  listMarkdownFilesByDateTool,\n  renameFileTool,\n  moveFileTool,\n  copyFileTool,\n  moveFilesBatchTool,\n  copyFilesBatchTool,\n  renameFilesBatchTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/system-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { skillManager } from '@/lib/skills'\nimport { executeSkillRuntime } from '@/lib/skills/runtime'\nimport useArticleStore from '@/stores/article'\n\nexport const getCurrentTimeTool: Tool = {\n  name: 'get_current_time',\n  description: 'Get the current date and time. Returns format: YYYY-MM-DD (e.g., 2026-01-18), which is suitable for direct use as part of a filename.',\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    try {\n      const now = new Date()\n\n      const year = now.getFullYear()\n      const month = String(now.getMonth() + 1).padStart(2, '0')\n      const day = String(now.getDate()).padStart(2, '0')\n\n      // 安全的文件名格式：YYYY-MM-DD\n      const safeFileNameDate = `${year}-${month}-${day}`\n\n      return {\n        success: true,\n        data: safeFileNameDate,\n        message: `当前日期：${safeFileNameDate}`,\n      }\n    } catch (error) {\n      console.error('[get_current_time] 获取失败', {\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `获取时间失败: ${error}`,\n      }\n    }\n  },\n}\n\n/**\n * 选择 Skill 工具\n * 用于 AI 在第一次迭代时选择合适的 Skill 来指导后续操作\n */\nexport const selectSkillTool: Tool = {\n  name: 'select_skill',\n  description: 'Select one or more Skills to guide task execution. On the first iteration, select the most relevant Skills based on the user task. After selection, complete Skill instructions will be provided in subsequent iterations.',\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'skill_ids',\n      type: 'array',\n      description: 'List of Skill IDs to select. Choose the most relevant Skills from the available Skills. You can check the ID field in the Skills list.',\n      required: true,\n    },\n  ],\n  execute: async (params: Record<string, any>): Promise<ToolResult> => {\n    try {\n      const { skill_ids } = params\n\n      if (!Array.isArray(skill_ids)) {\n        return {\n          success: false,\n          error: 'skill_ids 必须是一个数组',\n        }\n      }\n\n      // 验证所有 Skill ID 是否存在\n      const validSkills: string[] = []\n      const invalidSkills: string[] = []\n\n      for (const skillId of skill_ids) {\n        const skill = skillManager.getSkill(skillId)\n        if (skill) {\n          validSkills.push(skillId)\n        } else {\n          invalidSkills.push(skillId)\n        }\n      }\n\n      if (invalidSkills.length > 0) {\n        return {\n          success: false,\n          error: `无效的 Skill ID: ${invalidSkills.join(', ')}`,\n        }\n      }\n\n      if (validSkills.length === 0) {\n        return {\n          success: false,\n          error: '没有选择任何有效的 Skill',\n        }\n      }\n\n      return {\n        success: true,\n        data: {\n          selected_skills: validSkills,\n          count: validSkills.length,\n        },\n        message: `已选择 ${validSkills.length} 个 Skills: ${validSkills.join(', ')}。这些 Skills 的完整指令将在后续步骤中提供。`,\n      }\n    } catch (error) {\n      console.error('[select_skill] 执行失败', {\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `选择 Skill 失败: ${error}`,\n      }\n    }\n  },\n}\n\n/**\n * 加载 Skill 支持文件内容工具\n * 用于 AI 获取 Skill 的补充资料（如 KEYWORDS.md、EXAMPLES.md 等文件的内容）\n * 也支持加载根目录的自定义 .md 文件（如 editing.md, pptxgenjs.md）\n */\nexport const loadSkillContentTool: Tool = {\n  name: 'load_skill_content',\n  description: 'Get the support file content for the specified Skill. Supports standard files (KEYWORDS.md, EXAMPLES.md, REFERENCE.md) and custom root-level .md files (e.g., editing.md, pptxgenjs.md). These files contain detailed style guides, keyword lists, and usage examples to help better apply the Skill.',\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'skill_id',\n      type: 'string',\n      description: 'Skill ID, e.g., \"style-detector\"',\n      required: true,\n    },\n    {\n      name: 'file_type',\n      type: 'string',\n      description: 'File type or filename to load: supports \"keywords\" (KEYWORDS.md), \"examples\" (EXAMPLES.md), \"reference\" (REFERENCE.md), or a specific filename like \"editing.md\", \"pptxgenjs.md\". If not specified, returns all available support file content.',\n      required: false,\n    },\n  ],\n  execute: async (params: Record<string, any>): Promise<ToolResult> => {\n    try {\n      const { skill_id, file_type } = params\n\n      const skill = skillManager.getSkill(skill_id)\n      if (!skill) {\n        return {\n          success: false,\n          error: `未找到 Skill: ${skill_id}`,\n        }\n      }\n\n      // 获取 Skill 的文件信息\n      const fileInfo = skillManager.getSkillFileInfo(skill_id)\n      if (!fileInfo) {\n        return {\n          success: false,\n          error: `无法获取 Skill 文件信息: ${skill_id}`,\n        }\n      }\n\n      const results: Record<string, string> = {}\n\n      // 标准文件类型映射\n      const standardTypeMapping: Record<string, string> = {\n        keywords: 'KEYWORDS.md',\n        examples: 'EXAMPLES.md',\n        reference: 'REFERENCE.md',\n      }\n\n      // 读取文件内容\n      const { readTextFile, BaseDirectory } = await import('@tauri-apps/plugin-fs')\n      const { getFilePathOptions } = await import('@/lib/workspace')\n      const { exists } = await import('@tauri-apps/plugin-fs')\n\n      // 辅助函数：读取文件\n      const readFile = async (fileName: string, filePath: string): Promise<boolean> => {\n        let fileExists = false\n        if (skill.metadata.scope === 'global') {\n          fileExists = await exists(filePath, { baseDir: BaseDirectory.AppData })\n          if (fileExists) {\n            try {\n              results[fileName] = await readTextFile(filePath, { baseDir: BaseDirectory.AppData })\n              return true\n            } catch (error) {\n              console.error(`[load_skill_content] 读取文件失败: ${filePath}`, error)\n            }\n          }\n        } else {\n          const options = await getFilePathOptions(filePath)\n          fileExists = options.baseDir\n            ? await exists(options.path, { baseDir: options.baseDir })\n            : await exists(options.path)\n          if (fileExists) {\n            try {\n              if (options.baseDir) {\n                results[fileName] = await readTextFile(options.path, { baseDir: options.baseDir })\n              } else {\n                results[fileName] = await readTextFile(options.path)\n              }\n              return true\n            } catch (error) {\n              console.error(`[load_skill_content] 读取文件失败: ${filePath}`, error)\n            }\n          }\n        }\n        return false\n      }\n\n      if (file_type) {\n        // 指定了 file_type，尝试加载特定文件\n        const fileName = file_type\n\n        // 先检查是否是标准类型\n        const standardFile = standardTypeMapping[file_type]\n        if (standardFile) {\n          const filePath = `${fileInfo.directory}/${standardFile}`\n          await readFile(file_type, filePath)\n        } else {\n          // 可能是根目录的自定义 .md 文件（如 editing.md, pptxgenjs.md）\n          const filePath = `${fileInfo.directory}/${fileName}`\n          await readFile(fileName, filePath)\n        }\n      } else {\n        // 未指定 file_type，加载所有可用的支持文件\n        // 1. 加载标准文件\n        for (const [type, fileName] of Object.entries(standardTypeMapping)) {\n          const filePath = `${fileInfo.directory}/${fileName}`\n          await readFile(type, filePath)\n        }\n\n        // 2. 加载 Skill.references 中的根目录 .md 文件\n        // references 数组中的 rootMdFiles 有 path 属性（文件名而非完整路径）\n        for (const ref of skill.references) {\n          // 检查是否是根目录的 .md 文件（path 不包含目录分隔符）\n          if (!ref.path.includes('/') && ref.path.endsWith('.md') && ref.path !== 'SKILL.md') {\n            // 检查是否已经通过标准文件加载过了\n            const alreadyLoaded = Object.values(standardTypeMapping).includes(ref.path)\n            if (!alreadyLoaded) {\n              const filePath = `${fileInfo.directory}/${ref.path}`\n              await readFile(ref.name, filePath)\n            }\n          }\n        }\n      }\n\n      if (Object.keys(results).length === 0) {\n        return {\n          success: true,\n          data: {\n            skill_id,\n            available_files: skill.references.map(r => r.name),\n            message: '该 Skill 没有额外的支持文件，所有内容已包含在主 Skill 文件中。',\n          },\n          message: `Skill \"${skill_id}\" 没有找到额外的支持文件。所有必要信息已包含在主 Skill 指令中。`,\n        }\n      }\n\n      const loadedFiles = Object.keys(results)\n      const totalLength = Object.values(results).reduce((sum, content) => sum + content.length, 0)\n\n      return {\n        success: true,\n        data: {\n          skill_id,\n          loaded_files: loadedFiles,\n          files: results,\n          total_length: totalLength,\n        },\n        message: `成功加载 ${loadedFiles.length} 个支持文件（${loadedFiles.join(', ')}），共 ${totalLength} 字符。这些内容将帮助你更好地应用 ${skill_id} Skill。`,\n      }\n    } catch (error) {\n      console.error('[load_skill_content] 执行失败', {\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      })\n      return {\n        success: false,\n        error: `加载 Skill 内容失败: ${error}`,\n      }\n    }\n  },\n}\n\n/**\n * 执行 Skill 脚本工具\n * 用于 AI 在 Skill 目录上下文中执行 Python/Shell 脚本\n *\n * 支持的调用方式：\n * 1. 模块执行: command=\"python\", args=[\"-m\", \"markitdown\", \"file.pptx\"]\n * 2. 脚本执行: command=\"python\", args=[\"scripts/thumbnail.py\", \"file.pptx\"]\n * 3. 子目录脚本: command=\"python\", args=[\"scripts/office/unpack.py\", \"file.pptx\"]\n * 4. 整体命令: command=\"python -m markitdown file.pptx\", args=[]\n *\n * 重要说明：\n * - 工作目录会自动切换到 Skill 的根目录\n * - 脚本路径相对于 Skill 目录（如 \"scripts/office/unpack.py\"）\n * - 文件参数会自动从工作目录读取\n */\nexport const executeSkillScriptTool: Tool = {\n  name: 'execute_skill_script',\n  description: `Execute a Python or Shell script within a Skill directory context.\n\n**When to create a script file vs passing args:**\n- Use args for simple commands: \\`{\"command\": \"python\", \"args\": [\"-m\", \"markitdown\", \"file.pptx\"]}\\`\n- Create a script file for complex/long scripts, then execute it\n\n**Supported calling patterns:**\n1. Module execution: \\`{\"command\": \"python\", \"args\": [\"-m\", \"markitdown\", \"file.pptx\"]}\\`\n2. Script execution: \\`{\"command\": \"python\", \"args\": [\"scripts/thumbnail.py\", \"file.pptx\"]}\\`\n3. Nested script: \\`{\"command\": \"python\", \"args\": [\"scripts/office/unpack.py\", \"file.pptx\"]}\\`\n4. Full command: \\`{\"command\": \"python -m markitdown file.pptx\", \"args\": []}\\`\n\n**Key notes:**\n- Working directory is automatically set to the Skill's root directory (article/skills/{skill_id}/)\n- Temporary/generated scripts should live under \\`runtime/\\` inside the Skill directory\n- User-visible output files should be written to \\`article/outputs/{skill_id}/\\` whenever possible\n- TWO types of scripts:\n  1. **Skill's built-in scripts**: Use relative path like \"scripts/my-script.py\" (these exist in the skill directory)\n  2. **Runtime scripts**: Use bare filename like \"generate_ppt.js\" (these should be created in the Skill's \\`runtime/\\` directory and will be resolved automatically)\n- For runtime files: just pass the filename (e.g., \"generate_ppt.js\") - it will be resolved from the Skill's \\`runtime/\\` directory when present\n- For skill's scripts: use path relative to skill directory (e.g., \"scripts/thumbnail.py\")\n- If you need to pass complex or long script content, create a script file first using create_file, then execute it\n- The skill_id must match the Skill's ID (e.g., \"pptx\", \"pdf\")`,\n  category: 'system',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'skill_id',\n      type: 'string',\n      description: 'The ID of the Skill (e.g., \"pptx\", \"pdf\", \"weekly\")',\n      required: true,\n    },\n    {\n      name: 'command',\n      type: 'string',\n      description: 'The command to execute. Use \"python\" for Python modules/scripts, or a full command string (e.g., \"python -m markitdown\").',\n      required: true,\n    },\n    {\n      name: 'args',\n      type: 'array',\n      description: 'Arguments to pass to the command. Max 10 items. For scripts, include the script path relative to Skill directory (e.g., \"scripts/office/unpack.py\"). If you need to pass complex script content, create a script file first.',\n      required: false,\n    },\n    {\n      name: 'timeout',\n      type: 'number',\n      description: 'Timeout in milliseconds for script execution. Default is 60000ms (1 minute). Maximum is 300000ms (5 minutes).',\n      required: false,\n    },\n  ],\n  execute: async (params: Record<string, any>): Promise<ToolResult> => {\n    try {\n      const { skill_id, command, args, timeout } = params\n\n      if (!skill_id || typeof skill_id !== 'string') {\n        return {\n          success: false,\n          error: 'Invalid skill_id: must be a non-empty string',\n        }\n      }\n\n      if (!command || typeof command !== 'string') {\n        return {\n          success: false,\n          error: 'Invalid command: must be a non-empty string',\n        }\n      }\n\n      const outcome = await executeSkillRuntime({\n        skillId: skill_id,\n        command,\n        args: Array.isArray(args) ? args : [],\n        timeout,\n      })\n\n      if (outcome.success && Array.isArray(outcome.data?.output_files) && outcome.data.output_files.length > 0) {\n        const articleStore = useArticleStore.getState()\n        let insertedAny = false\n\n        for (const outputFile of outcome.data.output_files) {\n          const inserted = articleStore.insertLocalEntry(outputFile, false)\n          insertedAny = insertedAny || inserted\n          await articleStore.ensurePathExpanded(outputFile)\n        }\n\n        if (!insertedAny) {\n          await articleStore.loadFileTree()\n        }\n      }\n\n      return outcome\n    } catch (error) {\n      const errorMessage = error instanceof Error ? error.message : String(error)\n\n      console.error('[execute_skill_script] Execution error', {\n        error: errorMessage,\n        errorStack: error instanceof Error ? error.stack : undefined,\n      })\n\n      return {\n        success: false,\n        error: `Script execution error: ${errorMessage}`,\n      }\n    }\n  },\n}\n\nexport const systemTools: Tool[] = [\n  getCurrentTimeTool,\n  selectSkillTool,\n  loadSkillContentTool,\n  executeSkillScriptTool,\n]\n"
  },
  {
    "path": "src/lib/agent/tools/tag-tools.ts",
    "content": "import { Tool, ToolResult } from '../types'\nimport { getTags, insertTag, updateTag, delTag, Tag, insertTags } from '@/db/tags'\n\nexport const listTagsTool: Tool = {\n  name: 'list_tags',\n  description: 'List all tags (organization categories for marks).',\n  category: 'tag',\n  requiresConfirmation: false,\n  parameters: [],\n  execute: async (): Promise<ToolResult> => {\n    try {\n      const tags = await getTags()\n      return {\n        success: true,\n        data: tags,\n        message: `找到 ${tags.length} 个标签`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `获取标签列表失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createTagTool: Tool = {\n  name: 'create_tag',\n  description: 'Create a new tag (category) for organizing marks.',\n  category: 'tag',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'name',\n      type: 'string',\n      description: 'Tag name (e.g., \"Inbox\", \"Bookmarks\", \"Recipes\")',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const result = await insertTag({ name: params.name })\n      return {\n        success: true,\n        data: { id: result.lastInsertId },\n        message: `成功创建标签 \"${params.name}\"，ID: ${result.lastInsertId}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `创建标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateTagTool: Tool = {\n  name: 'update_tag',\n  description: 'Update tag name or properties (pin status).',\n  category: 'tag',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'Tag ID (use list_tags first to get tag IDs)',\n      required: true,\n    },\n    {\n      name: 'name',\n      type: 'string',\n      description: 'New tag name',\n      required: false,\n    },\n    {\n      name: 'isPin',\n      type: 'boolean',\n      description: 'Pin or unpin the tag',\n      required: false,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const tags = await getTags()\n      const tag = tags.find(t => t.id === params.id)\n      \n      if (!tag) {\n        return {\n          success: false,\n          error: `未找到ID为 ${params.id} 的标签`,\n        }\n      }\n      \n      const updatedTag: Tag = {\n        ...tag,\n        name: params.name !== undefined ? params.name : tag.name,\n        isPin: params.isPin !== undefined ? params.isPin : tag.isPin,\n      }\n      \n      await updateTag(updatedTag)\n      return {\n        success: true,\n        message: `成功更新标签 ID: ${params.id}`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `更新标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const searchTagsTool: Tool = {\n  name: 'search_tags',\n  description: 'Search tags by name (fuzzy match).',\n  category: 'search',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'query',\n      type: 'string',\n      description: 'Search keyword (fuzzy match on tag name)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const tags = await getTags()\n      const queryLower = params.query.toLowerCase()\n\n      const results = tags.filter(tag =>\n        tag.name.toLowerCase().includes(queryLower)\n      )\n\n      return {\n        success: true,\n        data: results,\n        message: `找到 ${results.length} 个匹配的标签`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `搜索标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const deleteTagTool: Tool = {\n  name: 'delete_tag',\n  description: 'Delete a tag and ALL marks under it. Use with caution.',\n  category: 'tag',\n  requiresConfirmation: true,\n  parameters: [\n    {\n      name: 'id',\n      type: 'number',\n      description: 'Tag ID to delete (use list_tags first to get tag IDs)',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      const tags = await getTags()\n      const tag = tags.find(t => t.id === params.id)\n      \n      if (!tag) {\n        return {\n          success: false,\n          error: `未找到ID为 ${params.id} 的标签`,\n        }\n      }\n      \n      if (tag.isLocked) {\n        return {\n          success: false,\n          error: `标签 \"${tag.name}\" 已锁定，无法删除`,\n        }\n      }\n      \n      await delTag(params.id)\n      return {\n        success: true,\n        message: `成功删除标签 \"${tag.name}\"`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `删除标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const createTagsBatchTool: Tool = {\n  name: 'create_tags_batch',\n  description: 'Batch create multiple tags to avoid loop calls. Use for scenarios requiring multiple tags to be created at once.',\n  category: 'tag',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tags',\n      type: 'array',\n      description: 'Array of tags to create, each tag contains name and other fields',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.tags) || params.tags.length === 0) {\n        return {\n          success: false,\n          error: '参数 tags 必须是非空数组',\n        }\n      }\n\n      const results = []\n      for (const tag of params.tags) {\n        const result = await insertTag({ name: tag.name })\n        results.push({ name: tag.name, id: result.lastInsertId })\n      }\n      \n      return {\n        success: true,\n        data: { count: results.length, tags: results },\n        message: `成功批量创建 ${results.length} 个标签`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量创建标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const updateTagsBatchTool: Tool = {\n  name: 'update_tags_batch',\n  description: 'Batch update multiple tags to avoid loop calls. Each tag must include the id field.',\n  category: 'tag',\n  requiresConfirmation: false,\n  parameters: [\n    {\n      name: 'tags',\n      type: 'array',\n      description: 'Array of tags to update, each tag must include id and fields to update',\n      required: true,\n    },\n  ],\n  execute: async (params): Promise<ToolResult> => {\n    try {\n      if (!Array.isArray(params.tags) || params.tags.length === 0) {\n        return {\n          success: false,\n          error: '参数 tags 必须是非空数组',\n        }\n      }\n\n      const allTags = await getTags()\n      const tagsToUpdate: Tag[] = []\n      \n      for (const tagUpdate of params.tags) {\n        const existingTag = allTags.find(t => t.id === tagUpdate.id)\n        if (!existingTag) {\n          return {\n            success: false,\n            error: `未找到ID为 ${tagUpdate.id} 的标签`,\n          }\n        }\n        \n        tagsToUpdate.push({\n          ...existingTag,\n          name: tagUpdate.name !== undefined ? tagUpdate.name : existingTag.name,\n          isPin: tagUpdate.isPin !== undefined ? tagUpdate.isPin : existingTag.isPin,\n          isLocked: tagUpdate.isLocked !== undefined ? tagUpdate.isLocked : existingTag.isLocked,\n          sortOrder: tagUpdate.sortOrder !== undefined ? tagUpdate.sortOrder : existingTag.sortOrder,\n        })\n      }\n\n      await insertTags(tagsToUpdate)\n      \n      return {\n        success: true,\n        data: { count: tagsToUpdate.length },\n        message: `成功批量更新 ${tagsToUpdate.length} 个标签`,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: `批量更新标签失败: ${error}`,\n      }\n    }\n  },\n}\n\nexport const tagTools: Tool[] = [\n  listTagsTool,\n  createTagTool,\n  updateTagTool,\n  deleteTagTool,\n  searchTagsTool,\n  createTagsBatchTool,\n  updateTagsBatchTool,\n]\n"
  },
  {
    "path": "src/lib/agent/types.ts",
    "content": "export type ToolParameterType = 'string' | 'number' | 'boolean' | 'array' | 'object'\n\nexport interface ToolParameter {\n  name: string\n  type: ToolParameterType\n  description: string\n  required: boolean\n  default?: any\n}\n\nexport interface Tool {\n  name: string\n  description: string\n  parameters: ToolParameter[]\n  requiresConfirmation: boolean\n  category: 'note' | 'chat' | 'tag' | 'mark' | 'search' | 'mcp' | 'system' | 'editor'\n  execute: (params: Record<string, any>) => Promise<ToolResult>\n}\n\nexport interface ToolResult {\n  success: boolean\n  data?: any\n  error?: string\n  message?: string\n}\n\nexport interface ToolCall {\n  id: string\n  toolName: string\n  params: Record<string, any>\n  result?: ToolResult\n  status: 'pending' | 'running' | 'success' | 'error'\n  timestamp: number\n}\n\nexport interface ConfirmationRecord {\n  toolName: string\n  params: Record<string, any>\n  status: 'pending' | 'confirmed' | 'cancelled'\n  timestamp: number\n  scope?: 'once' | 'conversation'\n  sessionApprovalType?: 'write' | 'runtime-script-skill'\n  sessionApprovalSkillId?: string\n}\n\nexport interface AgentState {\n  activeChatId?: number\n  isRunning: boolean\n  isThinking: boolean // 是否正在等待 AI 生成新的思考\n  currentThought: string\n  thoughtHistory: string[] // 累积的思考历史（已弃用，保留用于兼容）\n  completedSteps: ReActStep[] // 已完成的完整步骤（包含 thought, action, observation）\n  currentAction?: string\n  currentObservation?: string\n  toolCalls: ToolCall[]\n  maxIterations: number\n  currentIteration: number\n  pendingConfirmation?: {\n    toolName: string\n    params: Record<string, any>\n    originalContent?: string  // 原始内容（用于显示 diff）\n    modifiedContent?: string  // 修改后的内容（用于显示 diff）\n    filePath?: string         // 文件路径（用于显示在确认对话框中）\n    canApproveForSession?: boolean\n    sessionApprovalType?: 'write' | 'runtime-script-skill'\n    sessionApprovalSkillId?: string\n  }\n  confirmationHistory: ConfirmationRecord[] // 确认操作的历史记录\n  loadedSkills?: Array<{\n    id: string\n    name: string\n    description?: string\n  }> // 当前对话加载的 Skills 列表\n  selectedSkills?: string[] // AI 选择的 Skill ID 列表\n  currentStepStartTime?: number // 当前步骤开始时间戳（用于实时计算耗时）\n  // RAG 相关字段（实时执行时显示）\n  ragSources?: string[] // RAG 检索到的来源文件列表\n  ragSourceDetails?: Array<{\n    filepath: string\n    filename: string\n    content: string\n  }> // RAG 检索到的来源文件详情\n  // Final Answer 模式（检测到 Final Answer 时切换到 Markdown 渲染）\n  isFinalAnswerMode?: boolean\n  finalAnswerContent?: string\n}\n\nexport interface ReActStep {\n  thought: string\n  action?: {\n    tool: string\n    params: Record<string, any>\n  }\n  observation?: string\n  duration?: number  // 耗时（毫秒）\n}\n"
  },
  {
    "path": "src/lib/ai/chat.ts",
    "content": "import OpenAI from 'openai';\nimport { getAISettings, validateAIService, prepareMessages, createOpenAIClient, handleAIError, convertImageToBase64 } from './utils';\n\n/**\n * 非流式方式获取AI结果\n * @param text 请求文本\n * @param modelType 模型类型（可选）\n * @param messages 消息数组（可选，如果提供则忽略 text 参数）\n */\nexport async function fetchAi(\n  text: string,\n  modelType?: string,\n  messages?: OpenAI.Chat.ChatCompletionMessageParam[]\n): Promise<string> {\n  try {\n    // 获取AI设置\n    const aiConfig = await getAISettings(modelType)\n\n    // 验证AI服务\n    if (validateAIService(aiConfig?.baseURL) === null) return ''\n\n    // 准备消息\n    const prepared = await prepareMessages(text, messages)\n    const finalMessages = prepared.messages\n\n    const openai = await createOpenAIClient(aiConfig)\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: finalMessages,\n      temperature: aiConfig?.temperature || 1,\n      top_p: aiConfig?.topP || 1,\n    })\n\n    return completion.choices[0].message.content || ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 流式方式获取AI结果\n * @param text 请求文本\n * @param onUpdate 每次收到流式内容时的回调函数\n * @param abortSignal 用于终止请求的信号\n * @param mcpTools MCP 工具列表（可选）\n * @param t 翻译函数（可选）\n * @param chatId 当前chat ID，用于关联MCP工具调用记录（可选）\n * @param imageUrls 图片URL数组（可选）\n * @param onThinkingUpdate 每次收到思考内容时的回调函数（可选）\n * @param messages 消息数组（可选，如果提供则忽略 text 参数）\n */\nexport async function fetchAiStream(\n  text: string,\n  onUpdate: (content: string) => void,\n  abortSignal?: AbortSignal,\n  mcpTools?: any[],\n  t?: (key: string, params?: Record<string, any>) => string,\n  chatId?: number,\n  imageUrls?: string[],\n  onThinkingUpdate?: (thinking: string) => void,\n  messages?: OpenAI.Chat.ChatCompletionMessageParam[]\n): Promise<string> {\n  try {\n\n\n    // 获取AI设置\n    const aiConfig = await getAISettings()\n\n    // 验证AI服务\n    const validatedBaseURL = await validateAIService(aiConfig?.baseURL)\n    if (validatedBaseURL === null) {\n      return ''\n    }\n\n    // 准备消息 - 如果提供了 messages 数组，使用它；否则用 prepareMessages\n    let preparedMessages: OpenAI.Chat.ChatCompletionMessageParam[]\n    if (messages && messages.length > 0) {\n      // 使用提供的消息数组\n      const prepared = await prepareMessages('', messages)\n      preparedMessages = prepared.messages\n    } else {\n      const prepared = await prepareMessages(text)\n      preparedMessages = prepared.messages\n    }\n\n    // 如果有图片，将最后一条用户消息转换为多模态格式\n    if (imageUrls && imageUrls.length > 0) {\n      const lastMessage = preparedMessages[preparedMessages.length - 1]\n      if (lastMessage && lastMessage.role === 'user') {\n        const content: any[] = []\n\n        // 添加所有图片（转换为 base64）\n        for (const imageUrl of imageUrls) {\n          try {\n            // 将 Tauri URL 转换为 base64\n            const base64Image = await convertImageToBase64(imageUrl)\n            if (base64Image) {\n              content.push({\n                type: 'image_url',\n                image_url: {\n                  url: base64Image\n                }\n              })\n            }\n          } catch (error) {\n            console.error('Failed to convert image to base64:', error)\n          }\n        }\n\n        // 添加文本内容\n        content.push({\n          type: 'text',\n          text: typeof lastMessage.content === 'string' ? lastMessage.content : ''\n        })\n\n        // 替换最后一条消息\n        preparedMessages[preparedMessages.length - 1] = {\n          role: 'user',\n          content: content\n        }\n      }\n    }\n\n    const openai = await createOpenAIClient(aiConfig)\n\n    // 构建请求参数\n    const requestParams: any = {\n      model: aiConfig?.model || '',\n      messages: preparedMessages,\n      temperature: aiConfig?.temperature,\n      top_p: aiConfig?.topP,\n      stream: true,\n    }\n\n    // 如果有 MCP 工具，添加到请求中\n    if (mcpTools && mcpTools.length > 0) {\n      requestParams.tools = mcpTools\n      requestParams.tool_choice = 'auto'\n    }\n\n    const stream = await openai.chat.completions.create(requestParams, {\n      signal: abortSignal\n    }) as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>\n\n    let thinking = ''\n    let fullContent = ''\n    const toolCalls: any[] = []\n    let hasToolCalls = false\n    \n    for await (const chunk of stream) {\n      if (abortSignal?.aborted) {\n        break;\n      }\n      \n      const delta = chunk.choices[0]?.delta\n      const thinkingContent = (delta as any)?.reasoning_content || ''\n      const content = delta?.content || ''\n      \n      if (thinkingContent) {\n        // 处理思考内容\n      }\n      \n      // 处理工具调用\n      if (delta?.tool_calls) {\n        hasToolCalls = true\n        for (const toolCall of delta.tool_calls) {\n          const index = toolCall.index || 0\n          \n          // 初始化工具调用对象\n          if (!toolCalls[index]) {\n            toolCalls[index] = {\n              id: toolCall.id || '',\n              type: 'function',\n              function: {\n                name: toolCall.function?.name || '',\n                arguments: ''\n              }\n            }\n          }\n          \n          // 累积工具调用参数\n          if (toolCall.function?.arguments) {\n            toolCalls[index].function.arguments += toolCall.function.arguments\n          }\n          \n          // 更新其他字段\n          if (toolCall.id) {\n            toolCalls[index].id = toolCall.id\n          }\n          if (toolCall.function?.name) {\n            toolCalls[index].function.name = toolCall.function.name\n          }\n        }\n      }\n      \n      // 如果有工具调用，不显示中间内容，直接跳过\n      if (hasToolCalls) {\n        continue\n      }\n      \n      // 处理思考内容（通过独立回调）\n      if (thinkingContent) {\n        thinking += thinkingContent\n        if (onThinkingUpdate) {\n          onThinkingUpdate(thinking)\n        }\n      }\n      \n      // 处理普通内容\n      if (content) {\n        fullContent += content\n      }\n\n      onUpdate(fullContent)\n    }\n\n    // 如果有工具调用，执行工具并继续对话（支持多轮工具调用）\n    if (toolCalls.length > 0) {\n      // 动态导入 callTool 函数（避免循环依赖）\n      const { callTool } = await import('../mcp/tools')\n\n      // 初始化消息历史\n      let conversationMessages = [...preparedMessages]\n      let currentToolCalls = toolCalls\n      const maxIterations = 10 // 防止无限循环\n      let iteration = 0\n      \n      // 循环处理工具调用，直到 AI 不再调用工具\n      while (currentToolCalls.length > 0 && iteration < maxIterations) {\n        iteration++\n\n        onUpdate('')\n        \n        // 执行所有工具调用\n        const toolResults = []\n        for (const toolCall of currentToolCalls) {\n          let mcpToolCallId: string | undefined\n          try {\n            // 解析工具名称（格式：serverId__toolName）\n            const fullName = toolCall.function.name\n            const [serverId, ...toolNameParts] = fullName.split('__')\n            const toolName = toolNameParts.join('__')\n            \n            // 解析参数\n            let args = {}\n            try {\n              args = JSON.parse(toolCall.function.arguments)\n            } catch (parseError) {\n              const errorMsg = parseError instanceof Error ? parseError.message : 'Invalid JSON'\n              throw new Error(`Invalid JSON in tool arguments: ${errorMsg}. Raw arguments: ${toolCall.function.arguments.slice(0, 200)}`)\n            }\n            \n            // 记录 MCP 工具调用（如果提供了 chatId）\n            if (chatId) {\n              const { useMcpStore } = await import('@/stores/mcp')\n              const { default: useChatStore } = await import('@/stores/chat')\n              const mcpStore = useMcpStore.getState()\n              const chatStore = useChatStore.getState()\n              const server = mcpStore.servers.find(s => s.id === serverId)\n              \n              mcpToolCallId = `${toolCall.id}-${Date.now()}`\n              chatStore.addMcpToolCall({\n                id: mcpToolCallId,\n                chatId,\n                toolName,\n                serverId,\n                serverName: server?.name || serverId,\n                params: args,\n                result: '',\n                status: 'calling',\n                timestamp: Date.now()\n              })\n            }\n            \n            // 调用 MCP 工具\n            const result = await callTool(serverId, toolName, args)\n            \n            // 格式化结果\n            const resultText = result.content\n              .filter(c => c.type === 'text')\n              .map(c => c.text)\n              .join('\\n')\n            \n            // 更新 MCP 工具调用状态为成功\n            if (chatId && mcpToolCallId) {\n              const { default: useChatStore } = await import('@/stores/chat')\n              const chatStore = useChatStore.getState()\n              chatStore.updateMcpToolCall(mcpToolCallId, {\n                result: resultText || 'Tool executed successfully',\n                status: 'success'\n              })\n            }\n            \n            toolResults.push({\n              tool_call_id: toolCall.id,\n              role: 'tool' as const,\n              content: resultText || 'Tool executed successfully'\n            })\n            \n          } catch (error) {\n            console.error('工具调用失败:', error)\n            \n            // 更新 MCP 工具调用状态为错误\n            if (chatId && mcpToolCallId) {\n              const { default: useChatStore } = await import('@/stores/chat')\n              const chatStore = useChatStore.getState()\n              const errorMsg = error instanceof Error ? error.message : 'Unknown error'\n              chatStore.updateMcpToolCall(mcpToolCallId, {\n                result: `Error: ${errorMsg}`,\n                status: 'error'\n              })\n            }\n            \n            toolResults.push({\n              tool_call_id: toolCall.id,\n              role: 'tool' as const,\n              content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`\n            })\n          }\n        }\n        \n        // 将工具调用和结果添加到消息历史\n        conversationMessages = [\n          ...conversationMessages,\n          {\n            role: 'assistant' as const,\n            content: null,\n            tool_calls: currentToolCalls\n          },\n          ...toolResults\n        ]\n        \n        const nextStream = await openai.chat.completions.create({\n          model: aiConfig?.model || '',\n          messages: conversationMessages,\n          temperature: aiConfig?.temperature,\n          top_p: aiConfig?.topP,\n          stream: true,\n          tools: mcpTools,\n          tool_choice: 'auto'\n        }, {\n          signal: abortSignal\n        }) as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>\n        \n        // 重置工具调用数组\n        currentToolCalls = []\n        thinking = ''\n        fullContent = ''\n        \n        // 处理响应\n        for await (const chunk of nextStream) {\n          if (abortSignal?.aborted) {\n            break;\n          }\n          \n          const delta = chunk.choices[0]?.delta\n          const thinkingContent = (delta as any)?.reasoning_content || ''\n          const content = delta?.content || ''\n          \n          // 检查是否又有新的工具调用\n          if (delta?.tool_calls) {\n            for (const toolCall of delta.tool_calls) {\n              const index = toolCall.index || 0\n              \n              if (!currentToolCalls[index]) {\n                currentToolCalls[index] = {\n                  id: toolCall.id || '',\n                  type: 'function',\n                  function: {\n                    name: toolCall.function?.name || '',\n                    arguments: ''\n                  }\n                }\n              }\n              \n              if (toolCall.function?.arguments) {\n                currentToolCalls[index].function.arguments += toolCall.function.arguments\n              }\n              \n              if (toolCall.id) {\n                currentToolCalls[index].id = toolCall.id\n              }\n              if (toolCall.function?.name) {\n                currentToolCalls[index].function.name = toolCall.function.name\n              }\n            }\n          }\n          \n          // 如果有新的工具调用，不显示内容\n          if (currentToolCalls.length > 0) {\n            continue\n          }\n          \n          // 处理思考内容（通过独立回调）\n          if (thinkingContent) {\n            thinking += thinkingContent\n            if (onThinkingUpdate) {\n              onThinkingUpdate(thinking)\n            }\n          }\n          if (content) {\n            fullContent += content\n          }\n          onUpdate(fullContent)\n        }\n        \n        // 如果没有新的工具调用，退出循环\n        if (currentToolCalls.length === 0) {\n          break\n        }\n      }\n      \n      if (iteration >= maxIterations) {\n        console.warn('达到最大工具调用次数限制')\n        const maxIterationsText = t ? t('record.mark.mark.chat.mcp.maxIterationsReached') : '⚠️ 达到最大工具调用次数限制'\n        onUpdate(fullContent + '\\n\\n' + maxIterationsText)\n      }\n    }\n    \n    return fullContent\n  } catch (error) {\n    console.error('[fetchAiStream] Error:', error)\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 流式方式获取AI结果，每次返回本次 token\n * @param text 请求文本\n * @param onUpdate 每次收到流式内容时的回调函数\n * @param abortSignal 用于终止请求的信号\n */\nexport async function fetchAiStreamToken(text: string, onUpdate: (content: string) => void, abortSignal?: AbortSignal): Promise<string> {\n  try {\n    // 获取AI设置\n    const aiConfig = await getAISettings()\n    \n    // 验证AI服务\n    if (await validateAIService(aiConfig?.baseURL) === null) return ''\n    \n    // 准备消息\n    const { messages } = await prepareMessages(text)\n  \n    const openai = await createOpenAIClient(aiConfig)\n\n    const stream = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: messages,\n      temperature: aiConfig?.temperature,\n      top_p: aiConfig?.topP,\n      stream: true,\n    }, {\n      signal: abortSignal\n    })\n    \n    for await (const chunk of stream) {\n      if (abortSignal?.aborted) {\n        break;\n      }\n      \n      const content = chunk.choices[0]?.delta?.content || ''\n      if (content) {\n        onUpdate(content)\n      }\n    }\n    \n    return ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/completion.ts",
    "content": "import { getAISettings, validateAIService, createOpenAIClient, handleAIError } from './utils';\n\n/**\n * 清理补全结果\n */\nfunction cleanupCompletion(text: string): string {\n  return text\n    .trim()\n    .replace(/^```[\\s\\S]*?```$/g, '')\n    .replace(/^```\\w*\\s*/g, '')\n    .replace(/\\s*```$/g, '')\n    .replace(/^[\\s\\n]+|[\\s\\n]+$/g, '')\n    .replace(/^[\"'\"\"жат]|[\"'\"\"жат]$/g, '')\n    .replace(/^续写[：:]\\s*/i, '')\n    .replace(/^补全[：:]\\s*/i, '')\n    .replace(/^Continuation[:\\s]*/i, '')\n    .trim()\n}\n\n/**\n * 快速生成代码/文本补全\n * 专门用于内联补全，使用更少的上下文和更快的响应\n */\nexport async function fetchCompletion(context: string, abortSignal?: AbortSignal): Promise<string> {\n  try {\n    // 获取AI设置（使用快速补全模型）\n    const aiConfig = await getAISettings('completionModel')\n\n    // 验证AI服务\n    if (validateAIService(aiConfig?.baseURL) === null) return ''\n\n    const openai = await createOpenAIClient(aiConfig)\n\n    // 构建简洁的补全 prompt\n    const prompt = `Continue the following text naturally. Requirements:\n- Return ONLY the continuation text (1 sentence)\n- Use the same language as the context\n- Do NOT use code blocks, markdown formatting, or special syntax\n- Return plain text only\n\nContext:\n${context}\n\nContinuation:`\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: [\n        {\n          role: 'user',\n          content: prompt,\n        }\n      ],\n      temperature: 0.7,\n      max_tokens: 80,\n      top_p: 0.95,\n    }, {\n      signal: abortSignal\n    })\n\n    const result = completion.choices[0].message.content || ''\n    return cleanupCompletion(result)\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 流式获取补全结果\n * 实时将生成的文本插入到编辑器中\n */\nexport async function fetchCompletionStream(\n  context: string,\n  onChunk: (chunk: string, isFirst: boolean) => void,\n  abortSignal?: AbortSignal\n): Promise<void> {\n  try {\n    // 获取AI设置（使用快速补全模型）\n    const aiConfig = await getAISettings('completionModel')\n\n    // 验证AI服务\n    if (validateAIService(aiConfig?.baseURL) === null) return\n\n    const openai = await createOpenAIClient(aiConfig)\n\n    // 构建简洁的补全 prompt\n    const prompt = `Continue the following text naturally. Requirements:\n- Return ONLY the continuation text (1 sentence)\n- Use the same language as the context\n- Do NOT use code blocks, markdown formatting, or special syntax\n- Return plain text only\n\nContext:\n${context}\n\nContinuation:`\n\n    const stream = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: [\n        {\n          role: 'user',\n          content: prompt,\n        }\n      ],\n      temperature: 0.7,\n      max_tokens: 80,\n      top_p: 0.95,\n      stream: true,\n    }, {\n      signal: abortSignal\n    })\n\n    let isFirst = true\n    for await (const chunk of stream) {\n      const content = chunk.choices[0]?.delta?.content\n      if (content) {\n        const cleaned = cleanupCompletion(content)\n        if (cleaned) {\n          onChunk(cleaned, isFirst)\n          isFirst = false\n        }\n      }\n    }\n  } catch (error) {\n    // 对于 abort 请求，静默处理不抛出错误\n    if (error instanceof Error && error.name === 'AbortError') {\n      return\n    }\n    // 其他错误重新抛出\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/condense.ts",
    "content": "import { fetchAi } from './chat'\nimport { Chat } from '@/db/chats'\nimport { estimateTokens } from './token-counter'\nimport useSettingStore from '@/stores/setting'\nexport { getChatsAfterLastClear, buildChatHistoryForAI, buildMessagesWithHistory } from './history-messages'\n\nconst CONDENSE_THRESHOLD = 3 // AI 消息超过 3 条时检查压缩\nconst MIN_TOKEN_TO_CONDENSE = 100 // 单条消息超过 100 token 才压缩\n\n/**\n * 获取可压缩的 AI 消息（排除用户消息和已压缩的）\n * 规则：\n * - 用户消息永不压缩\n * - 最新的 N 条 AI 消息不压缩\n * - 已有摘要的消息不重复压缩\n */\nfunction getCondensableChats(chats: Chat[], keepLatestCount: number): Chat[] {\n  // 只处理 AI (system) 的 chat 和 note 类型消息\n  const aiMessages = chats.filter(c =>\n    (c.type === 'chat' || c.type === 'note') &&\n    c.role === 'system'\n  )\n\n  // 排除最新的 N 条\n  const toCheck = aiMessages.slice(0, -keepLatestCount)\n\n  // 只返回没有摘要的消息\n  return toCheck.filter(c => !c.condensedContent)\n}\n\n/**\n * 检查是否需要压缩\n */\nexport async function shouldCondense(chatsAfterClear: Chat[]): Promise<boolean> {\n  const settings = useSettingStore.getState()\n\n  // 检查是否启用摘要\n  if (!settings.enableCondense) {\n    return false\n  }\n\n  // 获取可压缩的 AI 消息\n  const condensableChats = getCondensableChats(chatsAfterClear, settings.keepLatestCount)\n\n  if (condensableChats.length < CONDENSE_THRESHOLD) {\n    return false\n  }\n\n  // 检查这些消息中是否有需要压缩的（超过 token 阈值）\n  const needsCondense = condensableChats.some(chat =>\n    estimateTokens(chat.content || '') > MIN_TOKEN_TO_CONDENSE\n  )\n\n  return needsCondense\n}\n\n/**\n * 为多条消息生成摘要\n * @returns 每条消息的摘要结果数组\n */\nexport async function condenseChats(chatsAfterClear: Chat[]): Promise<Array<{ chatId: number, summary: string | null }>> {\n  const settings = useSettingStore.getState()\n\n  // 检查是否启用摘要\n  if (!settings.enableCondense) {\n    return []\n  }\n\n  // 获取需要压缩的消息\n  const toCondense = getCondensableChats(chatsAfterClear, settings.keepLatestCount)\n\n  if (toCondense.length === 0) {\n    return []\n  }\n\n  // 获取用户配置的摘要模型\n  const { condenseModel } = settings\n  const hasCondenseModel = !!condenseModel\n\n  // 如果配置了 condenseModel，使用 'condenseModel' store key，否则使用 'primaryModel'\n  const storeKey = hasCondenseModel ? 'condenseModel' : 'primaryModel'\n\n  // 构建提示词\n  const prompt = `请将以下对话内容压缩为简洁的摘要，用于节省 token 使用量。\n\n压缩原则：\n1. 保留代码块、数据、结论、TODO 等关键信息\n2. 简化过程描述和中间思考\n3. 使用清晰的段落或要点组织内容\n4. 控制在 ${settings.condenseMaxLength} 字以内\n\n原始内容：\n{content}\n\n请输出摘要：`\n\n  const results: Array<{ chatId: number, summary: string | null }> = []\n\n  // 为每条消息生成摘要\n  for (const chat of toCondense) {\n    const content = chat.content || ''\n    const originalTokenCount = estimateTokens(content)\n\n    // 只压缩超过阈值的消息\n    if (originalTokenCount <= MIN_TOKEN_TO_CONDENSE) {\n      results.push({ chatId: chat.id, summary: null })\n      continue\n    }\n\n    try {\n      const finalPrompt = prompt.replace('{content}', content)\n      const summary = await fetchAi(finalPrompt, storeKey)\n\n      if (summary) {\n        results.push({ chatId: chat.id, summary })\n      } else {\n        results.push({ chatId: chat.id, summary: null })\n      }\n    } catch (error) {\n      console.error('[Condense] 消息', chat.id, '摘要生成出错:', error)\n      results.push({ chatId: chat.id, summary: null })\n    }\n  }\n\n  return results\n}\n"
  },
  {
    "path": "src/lib/ai/description.ts",
    "content": "import OpenAI from 'openai';\nimport { getAISettings, prepareMessages, createOpenAIClient, handleAIError } from './utils';\n\n/**\n * 生成文本描述\n * @param text 文本内容\n * @returns 描述文本\n */\nexport async function fetchAiDesc(text: string) {\n  try {\n    // 获取AI设置\n    const aiConfig = await getAISettings('markDescModel')\n    \n    const descContent = `Based on the screenshot content: ${text}, return a description. Keep it under 50 characters and avoid special characters.`\n    \n    // 准备消息\n    const { messages } = await prepareMessages(descContent)\n    \n    const openai = await createOpenAIClient(aiConfig)\n    const completion = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: messages,\n      temperature: aiConfig?.temperature || 1,\n      top_p: aiConfig?.topP || 1,\n    })\n    \n    return completion.choices[0].message.content || ''\n  } catch (error) {\n    handleAIError(error, false)\n    return null\n  }\n}\n\n/**\n * 通过图片生成描述\n * @param base64 图片的base64编码\n * @returns 描述文本\n */\nexport async function fetchAiDescByImage(base64: string) {\n  try {\n    // 获取AI设置\n    const aiConfig = await getAISettings('imageMethodModel')\n\n    const descContent = `Based on the screenshot content, return a description.`\n\n    // 使用 prepareMessages 获取包含记忆上下文的消息\n    const { messages: preparedMessages } = await prepareMessages(descContent)\n\n    const openai = await createOpenAIClient(aiConfig)\n\n    // 将最后一条用户消息转换为多模态格式（包含图片）\n    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []\n    for (let i = 0; i < preparedMessages.length; i++) {\n      const msg = preparedMessages[i]\n\n      if (i === preparedMessages.length - 1 && msg.role === 'user') {\n        // 最后一条消息：转换为多模态格式（图片 + 文本）\n        const textContent = typeof msg.content === 'string' ? msg.content : descContent\n        messages.push({\n          role: 'user',\n          content: [\n            {\n              type: 'image_url',\n              image_url: {\n                url: base64\n              }\n            },\n            {\n              type: 'text',\n              text: textContent\n            }\n          ]\n        })\n      } else {\n        // 其他消息：保持原样\n        messages.push(msg)\n      }\n    }\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: messages,\n      temperature: aiConfig?.temperature || 1,\n      top_p: aiConfig?.topP || 1,\n    })\n\n    return completion.choices[0].message.content || ''\n  } catch (error) {\n    handleAIError(error, false)\n    return null\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/embedding.ts",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { AiConfig } from \"@/app/core/setting/config\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\nimport { handleAIError } from \"./utils\";\n\n// 嵌入请求响应类型\ninterface EmbeddingResponse {\n  object: string;\n  model: string;\n  data: Array<{\n    object: string;\n    embedding: number[];\n    index: number;\n  }>;\n  usage: {\n    prompt_tokens: number;\n    total_tokens: number;\n  };\n}\n\n/**\n * 获取嵌入模型信息\n */\nasync function getEmbeddingModelInfo() {\n  const store = await Store.load('store.json');\n  const embeddingModel = await store.get<string>('embeddingModel');\n  if (!embeddingModel) return null;\n  \n  const aiModelList = await store.get<AiConfig[]>('aiModelList');\n  if (!aiModelList) return null;\n  \n  // 在新的数据结构中，需要找到包含指定模型ID的配置\n  for (const config of aiModelList) {\n    // 检查新的 models 数组结构\n    if (config.models && config.models.length > 0) {\n      const targetModel = config.models.find(model => \n        model.id === embeddingModel && model.modelType === 'embedding'\n      );\n      if (targetModel) {\n        // 返回合并了模型配置的 AiConfig\n        return {\n          ...config,\n          model: targetModel.model,\n          modelType: targetModel.modelType,\n          temperature: targetModel.temperature,\n          topP: targetModel.topP,\n          voice: targetModel.voice,\n          enableStream: targetModel.enableStream\n        };\n      }\n    } else {\n      // 向后兼容：处理旧的单模型结构\n      if (config.key === embeddingModel && config.modelType === 'embedding') {\n        return config;\n      }\n    }\n  }\n  \n  return null;\n}\n\n/**\n * 获取重排序模型信息\n */\nexport async function getRerankModelInfo() {\n  const store = await Store.load('store.json');\n  const rerankModel = await store.get<string>('rerankingModel');\n  if (!rerankModel) return null;\n  \n  const aiModelList = await store.get<AiConfig[]>('aiModelList');\n  if (!aiModelList) return null;\n  \n  // 在新的数据结构中，需要找到包含指定模型ID的配置\n  for (const config of aiModelList) {\n    // 检查新的 models 数组结构\n    if (config.models && config.models.length > 0) {\n      const targetModel = config.models.find(model => \n        model.id === rerankModel && model.modelType === 'rerank'\n      );\n      if (targetModel) {\n        // 返回合并了模型配置的 AiConfig\n        return {\n          ...config,\n          model: targetModel.model,\n          modelType: targetModel.modelType,\n          temperature: targetModel.temperature,\n          topP: targetModel.topP,\n          voice: targetModel.voice,\n          enableStream: targetModel.enableStream\n        };\n      }\n    } else {\n      // 向后兼容：处理旧的单模型结构\n      if (config.key === rerankModel && config.modelType === 'rerank') {\n        return config;\n      }\n    }\n  }\n  \n  return null;\n}\n\n/**\n * 检查是否有重排序模型可用\n */\nexport async function checkRerankModelAvailable(): Promise<boolean> {\n  try {\n    // 获取重排序模型信息\n    const modelInfo = await getRerankModelInfo();\n    if (!modelInfo) return false;\n    \n    const { baseURL, apiKey, model } = modelInfo;\n    if (!baseURL || !model) return false;\n    \n    // 测试重排序模型\n    const testQuery = '测试查询';\n    const testDocuments = [\n      '这是一个测试文档', \n      '这是另一个测试文档'\n    ];\n    \n    // 发送测试请求\n    const response = await fetch(baseURL + '/rerank', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'Authorization': `Bearer ${apiKey}`\n      },\n      body: JSON.stringify({\n        model: model,\n        query: testQuery,\n        documents: testDocuments\n      })\n    });\n    \n    if (!response.ok) {\n      return false;\n    }\n    \n    const data = await response.json();\n    return !!(data && data.results);\n  } catch (error) {\n    console.error('重排序模型检查失败:', error);\n    return false;\n  }\n}\n\n/**\n * 请求嵌入向量\n * @param text 需要嵌入的文本\n * @returns 嵌入向量结果，如果失败则返回null\n */\nexport async function fetchEmbedding(text: string): Promise<number[] | null> {\n  try {\n    if (text.length) {\n      // 获取嵌入模型信息\n      const modelInfo = await getEmbeddingModelInfo();\n      if (!modelInfo) {\n        throw new Error('未配置嵌入模型或模型配置不正确');\n      }\n      \n      const { baseURL, apiKey, model } = modelInfo;\n\n      if (!baseURL || !model) {\n        throw new Error('嵌入模型配置不完整');\n      }\n      \n      // 发送嵌入请求\n      const response = await fetch(baseURL + '/embeddings', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n          'Authorization': `Bearer ${apiKey}`,\n          'Origin': \"\"\n        },\n        body: JSON.stringify({\n          model: model,\n          input: text,\n          encoding_format: 'float'\n        })\n      });\n\n      if (!response.ok) {\n        throw new Error(`嵌入请求失败: ${response.status} ${response.statusText}`);\n      }\n      \n      const data = await response.json() as EmbeddingResponse;\n      if (!data || !data.data || !data.data[0] || !data.data[0].embedding) {\n        throw new Error('嵌入结果格式不正确');\n      }\n      \n      return data.data[0].embedding;\n    }\n    \n    return null;\n  } catch (error) {\n    handleAIError(error);\n    return null;\n  }\n}\n\n/**\n * 使用重排序模型重新排序检索的文档\n * @param query 用户查询\n * @param documents 要重新排序的文档列表\n * @returns 重新排序后的文档列表\n */\nexport async function rerankDocuments(\n  query: string,\n  documents: {id: number, filename: string, content: string, similarity: number}[]\n): Promise<{id: number, filename: string, content: string, similarity: number}[]> {\n  try {\n    if (!documents.length) {\n      return documents;\n    }\n\n    const modelInfo = await getRerankModelInfo();\n    if (!modelInfo) {\n      return documents;\n    }\n\n    const { baseURL, apiKey, model } = modelInfo;\n\n    if (!baseURL || !model) {\n      return documents;\n    }\n\n    const passages = documents.map(doc => doc.content);\n\n    const response = await fetch(baseURL + '/rerank', {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'Authorization': `Bearer ${apiKey}`,\n        'Origin': \"\"\n      },\n      body: JSON.stringify({\n        model: model,\n        query: query,\n        documents: passages\n      })\n    });\n\n    if (!response.ok) {\n      throw new Error(`重排序请求失败: ${response.status} ${response.statusText}`);\n    }\n\n    const data = await response.json();\n\n    if (!data || !data.results) {\n      throw new Error('重排序结果格式不正确');\n    }\n\n    // 计算最高 rerank 分数，用于判断是否使用 rerank 结果\n    const maxRerankScore = Math.max(...data.results.map((r: any) => r.relevance_score || r.score || 0));\n    const RERANK_THRESHOLD = 0.1;\n\n    // 如果 rerank 模型认为没有相关文档（最高分太低），返回原始排序\n    if (maxRerankScore < RERANK_THRESHOLD) {\n      return documents;\n    }\n\n    const rerankResults = data.results.map((result: any, index: number) => {\n      const docIndex = result.document_index ?? result.index ?? index;\n      const originalDoc = documents[docIndex];\n      return {\n        ...originalDoc,\n        similarity: result.relevance_score || result.score || documents[index].similarity\n      };\n    }).filter((doc: any): doc is {id: number, filename: string, content: string, similarity: number} => doc !== undefined);\n\n    return rerankResults.sort((a: {similarity: number}, b: {similarity: number}) => b.similarity - a.similarity);\n  } catch (error) {\n    console.error('[Rerank] 重排序失败:', error);\n    return documents;\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/history-messages.ts",
    "content": "type ChatLike = {\n  role: string\n  type: string\n  content?: string | null\n  condensedContent?: string | null\n}\n\ntype MessageLike = {\n  role: 'system' | 'user' | 'assistant'\n  content: string\n}\n\n/**\n * 获取最后一次清除后的消息\n */\nexport function getChatsAfterLastClear<T extends ChatLike>(chats: T[]): T[] {\n  const lastClearIndex = chats.findLastIndex(c => c.type === 'clear')\n  return lastClearIndex === -1 ? chats : chats.slice(lastClearIndex + 1)\n}\n\n/**\n * 构建用于 AI 的消息历史\n */\nexport function buildChatHistoryForAI(chats: ChatLike[], systemPrompt?: string): MessageLike[] {\n  const chatsAfterClear = getChatsAfterLastClear(chats)\n  const messages: MessageLike[] = []\n\n  if (systemPrompt) {\n    messages.push({\n      role: 'system',\n      content: systemPrompt\n    })\n  }\n\n  for (const chat of chatsAfterClear) {\n    if (chat.type !== 'chat' && chat.type !== 'note') {\n      continue\n    }\n\n    const role: 'user' | 'assistant' = chat.role === 'user' ? 'user' : 'assistant'\n    const content = chat.role === 'user'\n      ? chat.content || ''\n      : chat.condensedContent || chat.content || ''\n\n    if (content) {\n      messages.push({ role, content })\n    }\n  }\n\n  return messages\n}\n\n/**\n * 构建包含对话历史的完整 messages 数组\n */\nexport function buildMessagesWithHistory(\n  chats: ChatLike[],\n  systemPrompt?: string,\n  additionalContext?: string,\n  currentUserInput?: string,\n  options?: {\n    includeAssistantMessages?: boolean\n    includeLatestUserMessage?: boolean\n    maxUserMessages?: number\n  }\n): MessageLike[] {\n  const messages: MessageLike[] = []\n  const includeAssistantMessages = options?.includeAssistantMessages ?? true\n  const includeLatestUserMessage = options?.includeLatestUserMessage ?? true\n  const maxUserMessages = options?.maxUserMessages\n\n  if (systemPrompt) {\n    messages.push({\n      role: 'system',\n      content: systemPrompt\n    })\n  }\n\n  let chatsAfterClear = getChatsAfterLastClear(chats)\n\n  if (!includeLatestUserMessage) {\n    const lastUserIndex = [...chatsAfterClear].map(chat => chat.role).lastIndexOf('user')\n    if (lastUserIndex !== -1) {\n      chatsAfterClear = chatsAfterClear.filter((_, index) => index !== lastUserIndex)\n    }\n  }\n\n  if (typeof maxUserMessages === 'number' && maxUserMessages >= 0) {\n    const userIndexes = chatsAfterClear\n      .map((chat, index) => chat.role === 'user' ? index : -1)\n      .filter(index => index !== -1)\n    const allowedUserIndexes = new Set(userIndexes.slice(-maxUserMessages))\n    chatsAfterClear = chatsAfterClear.filter((chat, index) => {\n      if (chat.role !== 'user') {\n        return true\n      }\n\n      return allowedUserIndexes.has(index)\n    })\n  }\n\n  for (const chat of chatsAfterClear) {\n    if (chat.type !== 'chat' && chat.type !== 'note') {\n      continue\n    }\n\n    if (chat.role !== 'user' && !includeAssistantMessages) {\n      continue\n    }\n\n    const role: 'user' | 'assistant' = chat.role === 'user' ? 'user' : 'assistant'\n    const content = chat.role === 'user'\n      ? chat.content || ''\n      : chat.condensedContent || chat.content || ''\n\n    if (content) {\n      messages.push({ role, content })\n    }\n  }\n\n  if (additionalContext) {\n    messages.push({\n      role: 'system',\n      content: additionalContext\n    })\n  }\n\n  if (currentUserInput) {\n    messages.push({\n      role: 'user',\n      content: currentUserInput\n    })\n  }\n\n  return messages\n}\n"
  },
  {
    "path": "src/lib/ai/index.ts",
    "content": "// 导出所有模块的函数\nexport * from './utils';\nexport * from './chat';\nexport * from './embedding';\nexport * from './placeholder';\nexport * from './translate';\nexport * from './description';\nexport * from './rewrite';\n"
  },
  {
    "path": "src/lib/ai/placeholder.ts",
    "content": "import OpenAI from 'openai';\nimport useSettingStore from '@/stores/setting';\n\nexport interface QuickPrompt {\n  id: string\n  text: string\n}\n\n/**\n * 获取灵感模型配置\n * @returns 灵感模型配置，如果未配置则返回 null\n */\nasync function getInspirationModelConfig() {\n  const settingStore = useSettingStore.getState()\n  const inspirationModelId = settingStore.inspirationModel\n\n  // 从 AI 模型列表中查找配置的灵感模型\n  const aiModelList = settingStore.aiModelList\n  for (const config of aiModelList) {\n    if (config.models) {\n      const model = config.models.find(m => m.id === inspirationModelId || `${config.key}-${m.id}` === inspirationModelId)\n      if (model) {\n        return config\n      }\n    }\n  }\n\n  // 如果没找到配置的灵感模型，使用默认的 NoteGen 聊天模型作为 fallback\n  const { noteGenDefaultModels } = await import('@/app/model-config')\n  const noteGenChat = noteGenDefaultModels[0]?.models?.find(m => m.modelType === 'chat')\n  if (noteGenChat) {\n    return noteGenDefaultModels[0]\n  }\n\n  return null\n}\n\n/**\n * 生成输入框占位符建议\n * @param text 上下文内容\n * @returns 占位符文本，失败返回false\n */\nexport async function fetchAiPlaceholder(text: string): Promise<string | false> {\n  try {\n    // 动态导入 model-config 以获取默认模型配置\n    const { noteGenDefaultModels } = await import('@/app/model-config')\n\n    // 使用第一个默认模型配置（NoteGen Free）\n    const defaultConfig = noteGenDefaultModels[0]\n    const chatModel = defaultConfig.models?.find(m => m.modelType === 'chat')\n\n    if (!defaultConfig || !chatModel) {\n      console.error('No default chat model found in noteGenDefaultModels')\n      return false\n    }\n\n    // 构建 placeholder 提示词\n    const placeholderPrompt = `\n      You are a note-taking software with an intelligent assistant. You can refer to the recorded content to take notes.\n      IMPORTANT: Do not exceed 10 characters. Keep it extremely short.\n      There is only one line left. Line breaks are strictly prohibited.\n      Do not generate any special characters or punctuation.\n      Leave it as plain text and no format is required.\n      CRITICAL: Each response must be different and varied. Generate diverse suggestions each time, do not repeat previous patterns.\n      Generate a very short question based on the following content:\n      ${text}`\n\n    // 准备消息 - 不加载记忆，直接使用简单消息\n    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [\n      { role: 'user', content: placeholderPrompt }\n    ]\n\n    const openai = new OpenAI({\n      baseURL: defaultConfig.baseURL,\n      apiKey: defaultConfig.apiKey,\n      dangerouslyAllowBrowser: true,\n    })\n\n    const completion = await openai.chat.completions.create({\n      model: chatModel.model || '',\n      messages: messages,\n      temperature: chatModel.temperature || 1,\n      top_p: chatModel.topP || 1,\n    })\n\n    const result = completion.choices[0]?.message?.content || ''\n\n    // 去掉所有换行符和各种特殊符号，不包括空格\n    return result.trim()\n  } catch (error) {\n    console.error('Error in fetchAiPlaceholder:', error)\n    return false\n  }\n}\n\n/**\n * 生成4条灵感提示词\n * @param text 上下文内容\n * @returns 灵感提示词数组，失败返回空数组\n */\nexport async function fetchAiQuickPrompts(text: string): Promise<QuickPrompt[]> {\n  try {\n    const config = await getInspirationModelConfig()\n    const chatModel = config?.models?.find(m => m.modelType === 'chat')\n\n    if (!config || !chatModel) {\n      console.error('No valid chat model found for inspiration')\n      return []\n    }\n\n    // 构建生成4条提示词的 prompt\n    const prompt = `\nYou are a note-taking software assistant. Generate 4 different quick prompt suggestions.\n\nRequirements:\n1. Each prompt: short, actionable, under 15 characters\n2. All 4 prompts must be different\n3. Use Chinese unless content is clearly English\n4. NO special characters or punctuation\n5. Respond with ONLY a valid JSON array\n\nYour response must be exactly this format (nothing else):\n[\"prompt1\", \"prompt2\", \"prompt3\", \"prompt4\"]\n\nContent: ${text || 'General note-taking'}`\n\n    // 准备消息 - 不加载记忆，直接使用简单消息\n    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [\n      { role: 'user', content: prompt }\n    ]\n\n    const openai = new OpenAI({\n      baseURL: config.baseURL,\n      apiKey: config.apiKey,\n      dangerouslyAllowBrowser: true,\n    })\n\n    const completion = await openai.chat.completions.create({\n      model: chatModel.model || '',\n      messages: messages,\n      temperature: 0.8, // 使用较高的温度以获得更多样化的结果\n      top_p: chatModel.topP || 1,\n    })\n\n    const result = completion.choices[0]?.message?.content || ''\n\n    // 尝试解析 JSON 结果\n    try {\n      // 清理可能的 markdown 代码块标记\n      let cleanResult = result.replace(/```json\\n?/g, '').replace(/```\\n?/g, '').trim()\n\n      // 尝试提取 JSON 数组（处理返回文本中包含额外内容的情况）\n      const arrayMatch = cleanResult.match(/\\[[\\s\\S]*\\]/)\n      if (arrayMatch) {\n        cleanResult = arrayMatch[0]\n      }\n\n      // 尝试修复常见的 JSON 问题（如缺少引号）\n      try {\n        const prompts = JSON.parse(cleanResult)\n\n        if (Array.isArray(prompts) && prompts.length >= 4) {\n          return prompts.slice(0, 4).map((text, index) => ({\n            id: `ai-prompt-${index}`,\n            text: String(text).trim()\n          }))\n        }\n\n        // 如果解析的数组不足4条，返回能解析的部分\n        if (Array.isArray(prompts)) {\n          return prompts.map((text, index) => ({\n            id: `ai-prompt-${index}`,\n            text: String(text).trim()\n          }))\n        }\n      } catch {\n        // JSON parse failed, continue to fallback\n      }\n    } catch (parseError) {\n      console.error('Failed to parse AI response as JSON:', parseError)\n    }\n\n    // 如果 JSON 解析失败，尝试按行分割\n    const lines = result.split('\\n')\n      .map(line => line.trim())\n      .filter(line => line.length > 0 && !line.startsWith('[') && !line.startsWith(']'))\n\n    if (lines.length >= 4) {\n      return lines.slice(0, 4).map((text, index) => ({\n        id: `ai-prompt-${index}`,\n        text: text.replace(/^[\"']|[\"']$/g, '').trim()\n      }))\n    }\n\n    return []\n  } catch (error) {\n    console.error('Error in fetchAiQuickPrompts:', error)\n    return []\n  }\n}\n\n/**\n * 生成单个灵感提示词（用于 placeholder）\n * @param text 上下文内容\n * @returns 提示词文本，失败返回空字符串\n */\nexport async function fetchAiSinglePrompt(text: string): Promise<string> {\n  try {\n    const config = await getInspirationModelConfig()\n    const chatModel = config?.models?.find(m => m.modelType === 'chat')\n\n    if (!config || !chatModel) {\n      console.error('No valid chat model found for inspiration')\n      return ''\n    }\n\n    const prompt = `\nGenerate ONE very short and actionable prompt suggestion (under 15 characters) based on the following content.\nReturn ONLY the prompt text, nothing else.\nDo not include any special characters or punctuation.\n\nContent: ${text || 'No content provided'}`\n\n    // 准备消息 - 不加载记忆，直接使用简单消息\n    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [\n      { role: 'user', content: prompt }\n    ]\n\n    const openai = new OpenAI({\n      baseURL: config.baseURL,\n      apiKey: config.apiKey,\n      dangerouslyAllowBrowser: true,\n    })\n\n    const completion = await openai.chat.completions.create({\n      model: chatModel.model || '',\n      messages: messages,\n      temperature: 0.8,\n      top_p: chatModel.topP || 1,\n    })\n\n    const result = completion.choices[0]?.message?.content || ''\n    return result.trim()\n  } catch (error) {\n    console.error('Error in fetchAiSinglePrompt:', error)\n    return ''\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/rewrite.ts",
    "content": "import { getAISettings, prepareMessages, createOpenAIClient, handleAIError, validateAIService } from './utils';\n\n/**\n * 润色文本\n * @param text 要润色的文本\n * @returns 润色后的文本\n */\nexport async function fetchAiPolish(text: string): Promise<string> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const polishPrompt = `Polish the following text. Output ONLY the polished text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(polishPrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n    })\n\n    return completion.choices[0]?.message?.content || ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 精简文本\n * @param text 要精简的文本\n * @returns 精简后的文本\n */\nexport async function fetchAiConcise(text: string): Promise<string> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const concisePrompt = `Make the following text more concise. Output ONLY the concise text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(concisePrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n    })\n\n    return completion.choices[0]?.message?.content || ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 拓展文本\n * @param text 要拓展的文本\n * @returns 拓展后的文本\n */\nexport async function fetchAiExpand(text: string): Promise<string> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const expandPrompt = `Expand the following text with more details. Output ONLY the expanded text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(expandPrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const completion = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n    })\n\n    return completion.choices[0]?.message?.content || ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n\n/**\n * 流式润色文本\n * @param text 要润色的文本\n * @param onChunk 流式回调函数\n * @param abortSignal 中止信号\n */\nexport async function fetchAiPolishStream(\n  text: string,\n  onChunk: (chunk: string, isFirst: boolean) => void,\n  abortSignal?: AbortSignal\n): Promise<void> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const polishPrompt = `Polish the following text. Output ONLY the polished text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(polishPrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const stream = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n      stream: true,\n    }, {\n      signal: abortSignal\n    })\n\n    let isFirst = true\n    for await (const chunk of stream) {\n      const content = chunk.choices[0]?.delta?.content\n      if (content) {\n        onChunk(content, isFirst)\n        isFirst = false\n      }\n    }\n  } catch (error) {\n    if (error instanceof Error && error.name === 'AbortError') {\n      return\n    }\n    throw error\n  }\n}\n\n/**\n * 流式精简文本\n * @param text 要精简的文本\n * @param onChunk 流式回调函数\n * @param abortSignal 中止信号\n */\nexport async function fetchAiConciseStream(\n  text: string,\n  onChunk: (chunk: string, isFirst: boolean) => void,\n  abortSignal?: AbortSignal\n): Promise<void> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const concisePrompt = `Make the following text more concise. Output ONLY the concise text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(concisePrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const stream = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n      stream: true,\n    }, {\n      signal: abortSignal\n    })\n\n    let isFirst = true\n    for await (const chunk of stream) {\n      const content = chunk.choices[0]?.delta?.content\n      if (content) {\n        onChunk(content, isFirst)\n        isFirst = false\n      }\n    }\n  } catch (error) {\n    if (error instanceof Error && error.name === 'AbortError') {\n      return\n    }\n    throw error\n  }\n}\n\n/**\n * 流式拓展文本\n * @param text 要拓展的文本\n * @param onChunk 流式回调函数\n * @param abortSignal 中止信号\n */\nexport async function fetchAiExpandStream(\n  text: string,\n  onChunk: (chunk: string, isFirst: boolean) => void,\n  abortSignal?: AbortSignal\n): Promise<void> {\n  try {\n    const aiConfig = await getAISettings('primaryModel')\n\n    if (!aiConfig || validateAIService(aiConfig.baseURL) === null) {\n      throw new Error('AI service not configured')\n    }\n\n    const expandPrompt = `Expand the following text with more details. Output ONLY the expanded text, no explanations, no original text.\n\nInput:\n${text}\n\nOutput:`\n\n    const { messages } = await prepareMessages(expandPrompt)\n    const openai = await createOpenAIClient(aiConfig)\n\n    const stream = await openai.chat.completions.create({\n      model: aiConfig.model || '',\n      messages,\n      temperature: 0.7,\n      top_p: 0.95,\n      stream: true,\n    }, {\n      signal: abortSignal\n    })\n\n    let isFirst = true\n    for await (const chunk of stream) {\n      const content = chunk.choices[0]?.delta?.content\n      if (content) {\n        onChunk(content, isFirst)\n        isFirst = false\n      }\n    }\n  } catch (error) {\n    if (error instanceof Error && error.name === 'AbortError') {\n      return\n    }\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/token-counter.ts",
    "content": "import { Chat } from '@/db/chats'\n\n/**\n * 简单的 Token 估算（不依赖外部库）\n * 规则：中文约 1.5 字符/token，英文约 4 字符/token\n */\nexport function estimateTokens(text: string): number {\n  if (!text) return 0\n  const chineseChars = (text.match(/[\\u4e00-\\u9fa5]/g) || []).length\n  const otherChars = text.length - chineseChars\n  return Math.ceil(chineseChars / 1.5 + otherChars / 4)\n}\n\n/**\n * 计算 Chat 数组的总 token 量\n */\nexport function estimateChatTokens(chats: Chat[]): number {\n  return chats.reduce((sum, chat) => {\n    return sum + estimateTokens(chat.content || '')\n  }, 0)\n}\n\n/**\n * 计算用户消息的 token 总量\n */\nexport function estimateUserTokens(chats: Chat[]): number {\n  return chats\n    .filter(c => c.role === 'user')\n    .reduce((sum, chat) => sum + estimateTokens(chat.content || ''), 0)\n}\n"
  },
  {
    "path": "src/lib/ai/translate.ts",
    "content": "import { getAISettings, prepareMessages, createOpenAIClient, handleAIError } from './utils';\n\n/**\n * 翻译文本\n * @param text 要翻译的文本\n * @param targetLanguage 目标语言\n * @returns 翻译后的文本\n */\nexport async function fetchAiTranslate(text: string, targetLanguage: string): Promise<string> {\n  try {\n    // 获取AI设置\n    const aiConfig = await getAISettings('translateModel')\n    \n    // 构建翻译提示词\n    const translationPrompt = `Translate the following text to ${targetLanguage}. Maintain the original formatting, markdown syntax, and structure:`\n    \n    // 准备消息\n    const { messages } = await prepareMessages(`${translationPrompt}\\n\\n${text}`)\n    const openai = await createOpenAIClient(aiConfig)\n    \n    const completion = await openai.chat.completions.create({\n      model: aiConfig?.model || '',\n      messages: messages,\n      temperature: aiConfig?.temperature || 1,\n      top_p: aiConfig?.topP || 1,\n    })\n    \n    return completion.choices[0]?.message?.content || ''\n  } catch (error) {\n    return handleAIError(error) || ''\n  }\n}\n"
  },
  {
    "path": "src/lib/ai/types.ts",
    "content": "export interface OpenAIResult {\n  id: string;\n  choices: OpenAIChoice[];\n  created: number;\n  model: string;\n  object: string;\n  usage: Usage;\n  system_fingerprint: string;\n}\n\ninterface Usage {\n  prompt_tokens: number;\n  completion_tokens: number;\n  total_tokens: number;\n}\n\ninterface OpenAIChoice {\n  index: number;\n  message: Message;\n  logprobs: null;\n  finish_reason: string;\n}\n\ninterface Message {\n  role: string;\n  content: string;\n}\n\nexport interface AiModel {\n  id: string;\n  object: string;\n  created: number;\n  owned_by: string;\n}\n\nexport interface GeminiResult {\n  candidates: GeminiCandidate[];\n  promptFeedback: PromptFeedback;\n}\n\ninterface GeminiCandidate {\n  content: {\n    parts: {\n      text: string;\n    }[];\n    role: string;\n  };\n  finishReason: string;\n  index: number;\n  safetyRatings: SafetyRating[];\n}\n\ninterface SafetyRating {\n  category: string;\n  probability: string;\n}\n\ninterface PromptFeedback {\n  safetyRatings: SafetyRating[];\n}"
  },
  {
    "path": "src/lib/ai/utils.ts",
    "content": "import { toast } from \"@/hooks/use-toast\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport OpenAI from 'openai';\nimport { AiConfig } from \"@/app/core/setting/config\";\nimport { readFile } from \"@tauri-apps/plugin-fs\";\n\n/**\n * 获取当前的prompt内容\n */\nexport async function getPromptContent(): Promise<string> {\n  const store = await Store.load('store.json')\n  const currentPromptId = await store.get<string>('currentPromptId')\n  let promptContent = ''\n  \n  if (currentPromptId) {\n    const promptList = await store.get<Array<{id: string, content: string}>>('promptList')\n    if (promptList) {\n      const currentPrompt = promptList.find(prompt => prompt.id === currentPromptId)\n      if (currentPrompt && currentPrompt.content) {\n        promptContent = currentPrompt.content\n      }\n    }\n  }\n  \n  return promptContent\n}\n\n/**\n * 获取AI设置\n */\nexport async function getAISettings(modelType?: string): Promise<AiConfig | undefined> {\n  const store = await Store.load('store.json')\n  const aiConfigs = await store.get<AiConfig[]>('aiModelList')\n  const modelId = await store.get(modelType || 'primaryModel')\n\n  if (!modelId || !aiConfigs) {\n    return undefined\n  }\n\n  // 在新的数据结构中，需要找到包含指定模型ID的配置\n  for (const config of aiConfigs) {\n    // 检查新的 models 数组结构\n    if (config.models && config.models.length > 0) {\n      // 首先尝试直接匹配模型ID\n      let targetModel = config.models.find(model => model.id === modelId)\n\n      // 如果没找到，尝试匹配组合键格式 ${config.key}-${model.id}\n      if (!targetModel && typeof modelId === 'string' && modelId.includes('-')) {\n        const expectedPrefix = `${config.key}-`\n        if (modelId.startsWith(expectedPrefix)) {\n          const originalModelId = modelId.substring(expectedPrefix.length)\n          targetModel = config.models.find(model => model.id === originalModelId)\n        }\n      }\n\n      if (targetModel) {\n        const result = {\n          ...config,\n          model: targetModel.model,\n          modelType: targetModel.modelType,\n          temperature: targetModel.temperature,\n          topP: targetModel.topP,\n          voice: targetModel.voice,\n          enableStream: targetModel.enableStream\n        }\n        return result\n      }\n    } else {\n      // 向后兼容：处理旧的单模型结构\n      if (config.key === modelId) {\n        return config\n      }\n    }\n  }\n\n  return undefined\n}\n\n/**\n * 检查AI服务配置是否有效\n */\nexport async function validateAIService(baseURL: string | undefined): Promise<string | null> {\n  if (!baseURL) {\n    toast({\n      title: 'AI 错误',\n      description: '请先设置 AI 地址',\n      variant: 'destructive',\n    })\n    return null\n  }\n  return baseURL\n}\n\n/**\n * 将图片 URL 转换为 base64 格式\n */\nexport async function convertImageToBase64(imageUrl: string): Promise<string | null> {\n  try {\n    // 如果已经是 base64 格式，直接返回\n    if (imageUrl.startsWith('data:image')) {\n      return imageUrl\n    }\n    \n    // 从 Tauri URL 中提取文件路径\n    // convertFileSrc 生成的 URL 格式类似: tauri://localhost/path 或 asset://localhost/path\n    let filePath = imageUrl\n    \n    // 移除 tauri:// 或 asset:// 协议前缀\n    if (imageUrl.startsWith('tauri://localhost/')) {\n      filePath = imageUrl.replace('tauri://localhost/', '')\n    } else if (imageUrl.startsWith('asset://localhost/')) {\n      filePath = imageUrl.replace('asset://localhost/', '')\n    } else if (imageUrl.startsWith('http://tauri.localhost/')) {\n      filePath = imageUrl.replace('http://tauri.localhost/', '')\n    }\n    \n    // URL 解码\n    filePath = decodeURIComponent(filePath)\n    \n    // 读取文件\n    const fileData = await readFile(filePath)\n    \n    // 转换为 base64\n    const base64 = btoa(\n      new Uint8Array(fileData).reduce((data, byte) => data + String.fromCharCode(byte), '')\n    )\n    \n    // 根据文件扩展名确定 MIME 类型\n    let mimeType = 'image/png'\n    if (filePath.toLowerCase().endsWith('.jpg') || filePath.toLowerCase().endsWith('.jpeg')) {\n      mimeType = 'image/jpeg'\n    } else if (filePath.toLowerCase().endsWith('.gif')) {\n      mimeType = 'image/gif'\n    } else if (filePath.toLowerCase().endsWith('.webp')) {\n      mimeType = 'image/webp'\n    }\n    \n    return `data:${mimeType};base64,${base64}`\n  } catch (error) {\n    console.error('Failed to convert image to base64:', error)\n    return null\n  }\n}\n\n/**\n * 处理AI请求错误\n */\nexport function handleAIError(error: any, showToast = true): string | null {\n  const errorMessage = error instanceof Error ? error.message : '未知错误'\n  // 检查是否是取消请求的错误，如果是则静默处理\n  if (error.message === 'Request was aborted.') {\n    // 静默处理取消请求，不显示任何消息\n    return null\n  }\n  \n  if (showToast) {\n    toast({\n      description: errorMessage || 'AI错误',\n      variant: 'destructive',\n    })\n  }\n  \n  return `请求失败: ${errorMessage}`\n}\n\n/**\n * 为不同AI类型准备消息\n * @param text 用户输入文本（如果提供了 baseMessages，此参数将作为最后一条用户消息）\n * @param baseMessages 基础消息数组（如对话历史），如果提供，将合并到返回结果中\n */\nexport async function prepareMessages(\n  text: string,\n  baseMessages?: OpenAI.Chat.ChatCompletionMessageParam[]\n): Promise<{\n  messages: OpenAI.Chat.ChatCompletionMessageParam[],\n  geminiText?: string\n}> {\n  // 获取prompt内容\n  let promptContent = await getPromptContent()\n\n  // 加载记忆上下文\n  try {\n    const { contextLoader } = await import('@/lib/context/loader')\n    // 确定用于检索记忆的查询文本\n    let queryText = text || ''\n    if (baseMessages && baseMessages.length > 0) {\n      // 如果提供了消息数组，使用最后一条用户消息作为查询\n      const lastUserMessage = [...baseMessages].reverse().find(m => m.role === 'user')\n      if (lastUserMessage) {\n        queryText = typeof lastUserMessage.content === 'string' ? lastUserMessage.content : queryText\n      }\n    }\n\n    if (queryText) {\n      const memoryContext = await contextLoader.getContextForQuery(queryText)\n      if (memoryContext.preferences.length > 0 || memoryContext.memory.length > 0) {\n        const memoryPrompt = contextLoader.formatMemoriesForPrompt(memoryContext)\n        promptContent += '\\n\\n' + memoryPrompt\n      }\n    }\n  } catch (error) {\n    // 如果记忆加载失败，不影响正常对话\n    console.error('Failed to load memory context:', error)\n  }\n\n  // 如果提供了基础消息数组，直接使用它\n  if (baseMessages && baseMessages.length > 0) {\n    // 检查是否已经有 system 消息\n    const hasSystemMessage = baseMessages.some(msg => msg.role === 'system')\n\n    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []\n\n    // 如果需要添加 system prompt 且当前没有 system 消息\n    if (promptContent && !hasSystemMessage) {\n      messages.push({\n        role: 'system',\n        content: promptContent\n      })\n    }\n\n    // 添加所有基础消息\n    messages.push(...baseMessages)\n\n    // 添加系统提示词（如果有且原消息中没有）\n    if (promptContent && hasSystemMessage) {\n      // 如果已有 system 消息，合并内容\n      const firstSystemIndex = messages.findIndex(msg => msg.role === 'system')\n      if (firstSystemIndex !== -1) {\n        const existingContent = typeof messages[firstSystemIndex].content === 'string'\n          ? messages[firstSystemIndex].content\n          : ''\n        messages[firstSystemIndex] = {\n          role: 'system',\n          content: existingContent + '\\n\\n' + promptContent\n        }\n      }\n    }\n\n    return { messages, geminiText: undefined }\n  }\n\n  // 定义消息数组（旧逻辑，保持向后兼容）\n  const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []\n  let geminiText: string | undefined\n\n  if (promptContent) {\n    messages.push({\n      role: 'system',\n      content: promptContent\n    })\n  }\n\n  messages.push({\n    role: 'user',\n    content: text\n  })\n\n  return { messages, geminiText }\n}\n\n/**\n * 创建OpenAI客户端，适用于所有AI类型\n */\nexport async function createOpenAIClient(AiConfig?: AiConfig) {\n  const store = await Store.load('store.json')\n  let baseURL\n  let apiKey\n  if (AiConfig) {\n    baseURL = AiConfig.baseURL\n    apiKey = AiConfig.apiKey\n  } else {\n    baseURL = await store.get<string>('baseURL')\n    apiKey = await store.get<string>('apiKey')\n  }\n  const proxyUrl = await store.get<string>('proxy')\n\n  // 创建OpenAI客户端\n  return new OpenAI({\n    apiKey: apiKey || '',\n    baseURL: baseURL,\n    dangerouslyAllowBrowser: true,\n    defaultHeaders:{\n      \"x-stainless-arch\": null,\n      \"x-stainless-lang\": null,\n      \"x-stainless-os\": null,\n      \"x-stainless-package-version\": null,\n      \"x-stainless-retry-count\": null,\n      \"x-stainless-runtime\": null,\n      \"x-stainless-runtime-version\": null,\n      \"x-stainless-timeout\": null,\n      ...(AiConfig?.customHeaders || {})\n    },\n    ...(proxyUrl ? { httpAgent: proxyUrl } : {})\n  })\n}\n"
  },
  {
    "path": "src/lib/audio-converter.ts",
    "content": "/**\n * 将音频 Blob 转换为 WAV 格式\n */\nexport async function convertToWav(audioBlob: Blob): Promise<Blob> {\n  try {\n    // 创建 Audio Context\n    const audioContext = new AudioContext()\n    \n    // 读取音频数据\n    const arrayBuffer = await audioBlob.arrayBuffer()\n    const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)\n    \n    // 转换为 WAV\n    const wavBlob = audioBufferToWav(audioBuffer)\n    \n    // 关闭 Audio Context\n    audioContext.close()\n    \n    return wavBlob\n  } catch (error) {\n    console.error('音频转换失败:', error)\n    // 如果转换失败，返回原始 Blob\n    return audioBlob\n  }\n}\n\n/**\n * 将 AudioBuffer 转换为 WAV Blob\n */\nfunction audioBufferToWav(audioBuffer: AudioBuffer): Blob {\n  const numberOfChannels = audioBuffer.numberOfChannels\n  const sampleRate = audioBuffer.sampleRate\n  const format = 1 // PCM\n  const bitDepth = 16\n  \n  let result\n  if (numberOfChannels === 2) {\n    result = interleave(\n      audioBuffer.getChannelData(0),\n      audioBuffer.getChannelData(1)\n    )\n  } else {\n    result = audioBuffer.getChannelData(0)\n  }\n  \n  const buffer = new ArrayBuffer(44 + result.length * 2)\n  const view = new DataView(buffer)\n  \n  // WAV 文件头\n  writeString(view, 0, 'RIFF')\n  view.setUint32(4, 36 + result.length * 2, true)\n  writeString(view, 8, 'WAVE')\n  writeString(view, 12, 'fmt ')\n  view.setUint32(16, 16, true)\n  view.setUint16(20, format, true)\n  view.setUint16(22, numberOfChannels, true)\n  view.setUint32(24, sampleRate, true)\n  view.setUint32(28, sampleRate * numberOfChannels * bitDepth / 8, true)\n  view.setUint16(32, numberOfChannels * bitDepth / 8, true)\n  view.setUint16(34, bitDepth, true)\n  writeString(view, 36, 'data')\n  view.setUint32(40, result.length * 2, true)\n  \n  // 写入音频数据\n  floatTo16BitPCM(view, 44, result)\n  \n  return new Blob([buffer], { type: 'audio/wav' })\n}\n\nfunction interleave(leftChannel: Float32Array, rightChannel: Float32Array): Float32Array {\n  const length = leftChannel.length + rightChannel.length\n  const result = new Float32Array(length)\n  \n  let inputIndex = 0\n  for (let i = 0; i < length;) {\n    result[i++] = leftChannel[inputIndex]\n    result[i++] = rightChannel[inputIndex]\n    inputIndex++\n  }\n  return result\n}\n\nfunction writeString(view: DataView, offset: number, string: string) {\n  for (let i = 0; i < string.length; i++) {\n    view.setUint8(offset + i, string.charCodeAt(i))\n  }\n}\n\nfunction floatTo16BitPCM(view: DataView, offset: number, input: Float32Array) {\n  for (let i = 0; i < input.length; i++, offset += 2) {\n    const s = Math.max(-1, Math.min(1, input[i]))\n    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)\n  }\n}\n"
  },
  {
    "path": "src/lib/audio.ts",
    "content": "import useSettingStore from '@/stores/setting'\nimport { resolvePreferredSpeechEngine } from '@/lib/speech/runtime.ts'\nimport type { SpeechTask } from '@/lib/speech/types.ts'\nimport { NO_TRANSCRIPTION_MESSAGE } from '@/lib/speech/transcription-fallback.ts'\n\n/**\n * 使用浏览器原生语音合成API进行朗读\n */\nexport function speakWithSystemVoice(\n  text: string, \n  speed: number = 1,\n  onStart?: () => void,\n  onEnd?: () => void\n): void {\n  if (!text.trim()) {\n    throw new Error('文本内容为空')\n  }\n\n  // 检查浏览器是否支持语音合成\n  if (!('speechSynthesis' in window)) {\n    throw new Error('当前浏览器不支持语音合成功能')\n  }\n\n  // 停止当前的语音合成\n  window.speechSynthesis.cancel()\n\n  const utterance = new SpeechSynthesisUtterance(text)\n  \n  // 设置语音参数\n  utterance.rate = Math.max(0.1, Math.min(10, speed)) // 限制速度范围\n  utterance.volume = 1\n  utterance.pitch = 1\n\n  // 设置事件监听器\n  if (onStart) {\n    utterance.onstart = onStart\n  }\n  \n  if (onEnd) {\n    utterance.onend = onEnd\n    utterance.onerror = onEnd\n  }\n\n  // 开始朗读\n  window.speechSynthesis.speak(utterance)\n}\n\n/**\n * 停止系统语音合成\n */\nexport function stopSystemVoice(): void {\n  if ('speechSynthesis' in window) {\n    window.speechSynthesis.cancel()\n  }\n}\n\nexport interface AudioSpeechRequest {\n  model: string\n  input: string\n  voice?: string\n  speed?: number\n}\n\nexport interface AudioSpeechResponse {\n  audio: ArrayBuffer\n}\n\nexport function resolveCurrentSpeechEngine(task: SpeechTask) {\n  const { audioModel, sttModel, textToSpeechMode, speechToTextMode } = useSettingStore.getState()\n\n  return resolvePreferredSpeechEngine(task, {\n    audioModel,\n    sttModel,\n    textToSpeechMode,\n    speechToTextMode,\n  })\n}\n\n/**\n * 调用音频AI模型接口生成语音\n */\nexport async function fetchAudioSpeech(text: string, customVoice?: string, customSpeed?: number): Promise<ArrayBuffer> {\n  const { aiModelList, audioModel } = useSettingStore.getState()\n  \n  if (!audioModel) {\n    throw new Error('未配置音频模型')\n  }\n\n  // 查找音频模型配置\n  let audioConfig = null\n  \n  // 在新的数据结构中，需要找到包含指定模型ID的配置\n  for (const config of aiModelList) {\n    // 检查新的 models 数组结构\n    if (config.models && config.models.length > 0) {\n      const targetModel = config.models.find(model => \n        model.id === audioModel && model.modelType === 'tts'\n      )\n      if (targetModel) {\n        // 返回合并了模型配置的 AiConfig\n        audioConfig = {\n          ...config,\n          model: targetModel.model,\n          modelType: targetModel.modelType,\n          temperature: targetModel.temperature,\n          topP: targetModel.topP,\n          voice: targetModel.voice,\n          enableStream: targetModel.enableStream\n        }\n        break\n      }\n    } else {\n      // 向后兼容：处理旧的单模型结构\n      if (config.key === audioModel && config.modelType === 'tts') {\n        audioConfig = config\n        break\n      }\n    }\n  }\n  \n  if (!audioConfig) {\n    throw new Error('未找到音频模型配置')\n  }\n\n  if (!audioConfig.baseURL || !audioConfig.apiKey) {\n    throw new Error('音频模型配置不完整')\n  }\n\n  // 使用自定义voice或配置的voice，默认为alloy\n  const voice = customVoice || audioConfig.voice || 'alloy'\n  // 使用自定义speed或配置的speed，默认为1\n  const speed = customSpeed !== undefined ? customSpeed : (audioConfig.speed !== undefined ? audioConfig.speed : 1)\n\n  const requestBody: AudioSpeechRequest = {\n    model: audioConfig.model || 'tts-1',\n    input: text,\n    voice: voice,\n    speed: speed\n  }\n\n  const headers: Record<string, string> = {\n    'Content-Type': 'application/json',\n    'Authorization': `Bearer ${audioConfig.apiKey}`\n  }\n\n  // 添加自定义头部\n  if (audioConfig.customHeaders) {\n    Object.assign(headers, audioConfig.customHeaders)\n  }\n\n  try {\n    const response = await fetch(`${audioConfig.baseURL}/audio/speech`, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify(requestBody)\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text()\n      throw new Error(`音频生成失败: ${response.status} ${errorText}`)\n    }\n\n    return await response.arrayBuffer()\n  } catch (error) {\n    console.error('音频生成错误:', error)\n    throw error\n  }\n}\n\n// 全局音频控制器\nlet currentAudioController: AudioController | null = null\n\n/**\n * 音频控制器类，支持播放和停止\n */\nclass AudioController {\n  private audioContext: AudioContext | null = null\n  private source: AudioBufferSourceNode | null = null\n  private isPlaying = false\n  private onPlayingChange?: (playing: boolean) => void\n\n  constructor(onPlayingChange?: (playing: boolean) => void) {\n    this.onPlayingChange = onPlayingChange\n  }\n\n  /**\n   * 播放音频数据\n   */\n  async playAudioBuffer(audioBuffer: ArrayBuffer): Promise<void> {\n    return new Promise((resolve, reject) => {\n      try {\n        // 如果已经在播放，先停止\n        this.stop()\n\n        this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()\n        \n        this.audioContext.decodeAudioData(\n          audioBuffer.slice(0), // 创建副本避免detached buffer问题\n          (decodedData) => {\n            if (!this.audioContext) {\n              reject(new Error('音频上下文已被销毁'))\n              return\n            }\n\n            this.source = this.audioContext.createBufferSource()\n            this.source.buffer = decodedData\n            this.source.connect(this.audioContext.destination)\n            \n            this.source.onended = () => {\n              this.cleanup()\n              this.onPlayingChange?.(false)\n              resolve()\n            }\n            \n            this.isPlaying = true\n            this.onPlayingChange?.(true)\n            this.source.start(0)\n          },\n          (error) => {\n            this.cleanup()\n            reject(new Error(`音频解码失败: ${error}`))\n          }\n        )\n      } catch (error) {\n        this.cleanup()\n        reject(new Error(`音频播放失败: ${error}`))\n      }\n    })\n  }\n\n  /**\n   * 停止播放\n   */\n  stop(): void {\n    if (this.source && this.isPlaying) {\n      try {\n        this.source.stop()\n      } catch {\n        // 忽略已经停止的错误\n      }\n    }\n    this.cleanup()\n    this.onPlayingChange?.(false)\n  }\n\n  /**\n   * 清理资源\n   */\n  private cleanup(): void {\n    this.isPlaying = false\n    this.source = null\n    if (this.audioContext) {\n      this.audioContext.close()\n      this.audioContext = null\n    }\n  }\n\n  /**\n   * 获取播放状态\n   */\n  getIsPlaying(): boolean {\n    return this.isPlaying\n  }\n}\n\n/**\n * 播放音频数据（向后兼容）\n */\nexport function playAudioBuffer(audioBuffer: ArrayBuffer): Promise<void> {\n  const controller = new AudioController()\n  return controller.playAudioBuffer(audioBuffer)\n}\n\n/**\n * 文本转语音并播放（支持状态回调）\n * 如果没有配置AI音频模型，则使用系统原生朗读功能\n */\nexport async function textToSpeechAndPlay(\n  text: string, \n  customVoice?: string,\n  customSpeed?: number,\n  onPlayingChange?: (playing: boolean) => void\n): Promise<void> {\n  if (!text.trim()) {\n    throw new Error('文本内容为空')\n  }\n\n  const resolution = resolveCurrentSpeechEngine('tts')\n\n  if (!resolution.available) {\n    throw new Error('当前朗读模式不可用，请检查本地语音支持或模型配置')\n  }\n\n  if (resolution.engine === 'local') {\n    try {\n      // 停止当前播放\n      stopCurrentAudio()\n      stopSystemVoice()\n      \n      if (onPlayingChange) {\n        onPlayingChange(true)\n      }\n      \n      const speed = customSpeed !== undefined ? customSpeed : 1\n      \n      speakWithSystemVoice(\n        text,\n        speed,\n        () => {\n          // 开始播放\n          if (onPlayingChange) {\n            onPlayingChange(true)\n          }\n        },\n        () => {\n          // 结束播放\n          if (onPlayingChange) {\n            onPlayingChange(false)\n          }\n        }\n      )\n      \n      return\n    } catch (error) {\n      if (onPlayingChange) {\n        onPlayingChange(false)\n      }\n      throw error\n    }\n  }\n\n  try {\n    // 停止当前播放\n    stopCurrentAudio()\n    stopSystemVoice()\n    \n    const audioBuffer = await fetchAudioSpeech(text, customVoice, customSpeed)\n    \n    // 创建新的音频控制器\n    currentAudioController = new AudioController(onPlayingChange)\n    await currentAudioController.playAudioBuffer(audioBuffer)\n  } catch (error) {\n    console.error('朗读失败:', error)\n    onPlayingChange?.(false)\n    throw error\n  }\n}\n\n/**\n * 停止当前播放的音频（包括AI音频和系统朗读）\n */\nexport function stopCurrentAudio(): void {\n  if (currentAudioController) {\n    currentAudioController.stop()\n    currentAudioController = null\n  }\n  // 同时停止系统朗读\n  stopSystemVoice()\n}\n\n/**\n * 获取当前音频播放状态\n */\nexport function getCurrentAudioPlayingState(): boolean {\n  return currentAudioController?.getIsPlaying() ?? false\n}\n\n/**\n * 语音转文本请求接口\n */\nexport interface AudioTranscriptionRequest {\n  file: Blob\n  model: string\n}\n\n/**\n * 语音转文本响应接口\n */\nexport interface AudioTranscriptionResponse {\n  text: string\n}\n\nexport { NO_TRANSCRIPTION_MESSAGE }\n\nexport async function transcribeRecording(audioBlob: Blob): Promise<string> {\n  const { sttModel } = useSettingStore.getState()\n\n  if (!sttModel) {\n    return ''\n  }\n\n  return fetchAudioTranscription(audioBlob)\n}\n\n/**\n * 调用STT模型将音频转换为文本\n */\nexport async function fetchAudioTranscription(audioBlob: Blob): Promise<string> {\n  const { aiModelList, sttModel } = useSettingStore.getState()\n  \n  if (!sttModel) {\n    throw new Error('未配置语音识别模型')\n  }\n\n  // 查找STT模型配置\n  let sttConfig = null\n  \n  // 在新的数据结构中，需要找到包含指定模型ID的配置\n  for (const config of aiModelList) {\n    // 检查新的 models 数组结构\n    if (config.models && config.models.length > 0) {\n      const targetModel = config.models.find(model => \n        model.id === sttModel && model.modelType === 'stt'\n      )\n      if (targetModel) {\n        // 返回合并了模型配置的 AiConfig\n        sttConfig = {\n          ...config,\n          model: targetModel.model,\n          modelType: targetModel.modelType\n        }\n        break\n      }\n    } else {\n      // 向后兼容：处理旧的单模型结构\n      if (config.key === sttModel && config.modelType === 'stt') {\n        sttConfig = config\n        break\n      }\n    }\n  }\n  \n  if (!sttConfig) {\n    throw new Error('未找到语音识别模型配置')\n  }\n\n  if (!sttConfig.baseURL || !sttConfig.apiKey) {\n    throw new Error('语音识别模型配置不完整')\n  }\n\n  // 创建 FormData\n  const formData = new FormData()\n  formData.append('file', audioBlob, 'audio.webm')\n  formData.append('model', sttConfig.model || 'FunAudioLLM/SenseVoiceSmall')\n\n  const headers: Record<string, string> = {\n    'Authorization': `Bearer ${sttConfig.apiKey}`\n  }\n\n  // 添加自定义头部\n  if (sttConfig.customHeaders) {\n    Object.assign(headers, sttConfig.customHeaders)\n  }\n\n  try {\n    const response = await fetch(`${sttConfig.baseURL}/audio/transcriptions`, {\n      method: 'POST',\n      headers,\n      body: formData\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text()\n      throw new Error(`语音识别失败: ${response.status} ${errorText}`)\n    }\n\n    const result: AudioTranscriptionResponse = await response.json()\n    return result.text\n  } catch (error) {\n    console.error('语音识别错误:', error)\n    throw error\n  }\n}\n"
  },
  {
    "path": "src/lib/bm25.ts",
    "content": "/**\n * BM25 检索模块\n * 中文友好的 BM25 算法实现，无需外部分词库\n */\n\n/**\n * 文档项结构\n */\nexport interface BM25Document {\n  id: string;           // 文档唯一标识（通常用文件名）\n  content: string;      // 文档内容\n}\n\n/**\n * 检索结果\n */\nexport interface BM25Result {\n  id: string;           // 文档ID\n  score: number;        // BM25 分数\n}\n\n/**\n * BM25 索引类\n */\nexport class BM25Index {\n  private documents: Map<string, string> = new Map(); // id -> content\n  private docVectors: Map<string, Map<string, number>> = new Map(); // id -> token -> frequency\n  private idfCache: Map<string, number> = new Map(); // token -> IDF\n  private docLengths: Map<string, number> = new Map(); // id -> document length\n  private averageDocLength: number = 0;\n\n  // BM25 参数\n  private k1: number;  // 词频饱和参数\n  private b: number;   // 长度归一化参数\n\n  constructor(k1: number = 1.2, b: number = 0.75) {\n    this.k1 = k1;\n    this.b = b;\n  }\n\n  /**\n   * 中文友好的分词函数\n   * 采用混合策略：边界分割 + 过滤单字 + 过滤数字\n   *\n   * 示例：\n   * \"RAG检索增强生成系统用于智能问答\"\n   * -> [\"RAG\", \"检索\", \"增强\", \"生成\", \"系统\", \"用于\", \"智能\", \"问答\"]\n   */\n  private tokenize(text: string): string[] {\n    // 1. 按边界分割：标点、空格、中英文边界\n    // 匹配：英文单词、数字、连续的中文（2个或以上）\n    const tokens: string[] = [];\n\n    // 正则表达式模式：\n    // - 英文单词/数字：[a-zA-Z0-9]+\n    // - 中文词语（2字以上）：[\\u4e00-\\u9fa5]{2,}\n    const pattern = /[a-zA-Z0-9]+|[\\u4e00-\\u9fa5]{2,}/g;\n\n    let match: RegExpExecArray | null;\n    while ((match = pattern.exec(text)) !== null) {\n      const token = match[0];\n\n      // 2. 过滤纯数字（如 \"123\", \"2024\"）\n      if (/^\\d+$/.test(token)) {\n        continue;\n      }\n\n      // 3. 转换为小写（英文）\n      const normalizedToken = token.toLowerCase();\n\n      tokens.push(normalizedToken);\n    }\n\n    return tokens;\n  }\n\n  /**\n   * 构建索引\n   * @param documents 文档列表\n   */\n  index(documents: BM25Document[]): void {\n    // 清空现有索引\n    this.documents.clear();\n    this.docVectors.clear();\n    this.idfCache.clear();\n    this.docLengths.clear();\n\n    const N = documents.length;\n    let totalLength = 0;\n\n    // 1. 处理每个文档\n    for (const doc of documents) {\n      const tokens = this.tokenize(doc.content);\n      const tokenFreq = new Map<string, number>();\n\n      // 计算词频\n      for (const token of tokens) {\n        tokenFreq.set(token, (tokenFreq.get(token) || 0) + 1);\n      }\n\n      // 存储文档和词频向量\n      this.documents.set(doc.id, doc.content);\n      this.docVectors.set(doc.id, tokenFreq);\n      this.docLengths.set(doc.id, tokens.length);\n      totalLength += tokens.length;\n    }\n\n    // 2. 计算平均文档长度\n    this.averageDocLength = N > 0 ? totalLength / N : 0;\n\n    // 3. 计算 IDF\n    this.calculateIDF(N);\n  }\n\n  /**\n   * 计算 IDF（逆文档频率）\n   * @param N 总文档数\n   */\n  private calculateIDF(N: number): void {\n    // 统计每个 token 出现在多少个文档中\n    const docFreq = new Map<string, number>();\n\n    for (const [, tokenFreq] of this.docVectors.entries()) {\n      for (const token of tokenFreq.keys()) {\n        docFreq.set(token, (docFreq.get(token) || 0) + 1);\n      }\n    }\n\n    // 计算 IDF：log((N - df + 0.5) / (df + 0.5) + 1)\n    for (const [token, df] of docFreq.entries()) {\n      const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);\n      this.idfCache.set(token, idf);\n    }\n  }\n\n  /**\n   * 搜索\n   * @param query 查询文本\n   * @param limit 返回结果数量限制\n   * @returns 排序后的检索结果\n   */\n  search(query: string, limit: number = 10): BM25Result[] {\n    const queryTokens = this.tokenize(query);\n\n    const results: Map<string, number> = new Map();\n\n    // 对每个文档计算 BM25 分数\n    for (const [docId, docVector] of this.docVectors.entries()) {\n      const docLength = this.docLengths.get(docId) || 0;\n      let score = 0;\n\n      // BM25 公式：\n      // score = Σ IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 - b + b * |D| / avgDl))\n      for (const token of queryTokens) {\n        // 检查 token 是否在文档中\n        const freq = docVector.get(token) || 0;\n        if (freq === 0) continue;\n\n        // 获取 IDF\n        const idf = this.idfCache.get(token) || 0;\n\n        // 计算 BM25 分数分量\n        const numerator = freq * (this.k1 + 1);\n        const denominator = freq + this.k1 * (1 - this.b + this.b * (docLength / this.averageDocLength));\n        const componentScore = idf * (numerator / denominator);\n\n        score += componentScore;\n      }\n\n      if (score > 0) {\n        results.set(docId, score);\n      }\n    }\n\n    // 按分数降序排序\n    const sortedResults = Array.from(results.entries())\n      .sort(([, a], [, b]) => b - a)\n      .slice(0, limit)\n      .map(([id, score]) => ({ id, score }));\n\n    return sortedResults;\n  }\n\n  /**\n   * 更新单个文档\n   * @param document 要更新的文档\n   */\n  update(document: BM25Document): void {\n    // 如果文档已存在，先删除\n    if (this.documents.has(document.id)) {\n      this.delete(document.id);\n    }\n\n    // 添加新文档\n    this.index([document]);\n  }\n\n  /**\n   * 删除文档\n   * @param docId 文档ID\n   */\n  delete(docId: string): void {\n    if (!this.documents.has(docId)) {\n      return;\n    }\n\n    // 删除文档\n    this.documents.delete(docId);\n    this.docVectors.delete(docId);\n    this.docLengths.delete(docId);\n\n    // 重新计算 IDF（因为文档频率变了）\n    this.calculateIDF(this.documents.size);\n\n    // 重新计算平均文档长度\n    const totalLength = Array.from(this.docLengths.values()).reduce((a, b) => a + b, 0);\n    this.averageDocLength = this.documents.size > 0 ? totalLength / this.documents.size : 0;\n  }\n\n  /**\n   * 获取索引中的文档数量\n   */\n  size(): number {\n    return this.documents.size;\n  }\n\n  /**\n   * 清空索引\n   */\n  clear(): void {\n    this.documents.clear();\n    this.docVectors.clear();\n    this.idfCache.clear();\n    this.docLengths.clear();\n    this.averageDocLength = 0;\n  }\n}\n\n/**\n * 全局 BM25 索引实例\n */\nlet globalBM25Index: BM25Index | null = null;\n\n/**\n * 初始化全局 BM25 索引\n * @param documents 文档列表\n */\nexport function initBM25Index(documents: BM25Document[]): BM25Index {\n  if (!globalBM25Index) {\n    globalBM25Index = new BM25Index();\n  }\n  globalBM25Index.index(documents);\n  return globalBM25Index;\n}\n\n/**\n * 获取全局 BM25 索引\n */\nexport function getBM25Index(): BM25Index | null {\n  return globalBM25Index;\n}\n\n/**\n * 清空全局 BM25 索引\n */\nexport function clearBM25Index(): void {\n  if (globalBM25Index) {\n    globalBM25Index.clear();\n    globalBM25Index = null;\n  }\n}\n"
  },
  {
    "path": "src/lib/check.ts",
    "content": "import { platform } from \"@tauri-apps/plugin-os\";\n\n// 缓存平台检测结果\nlet cachedResult: boolean | null = null;\nlet cachedTauriResult: boolean | null = null;\n\n// 异步检查是否为移动设备的函数\nexport function isMobileDevice() {\n  // 如果已经检测过，直接返回缓存结果\n  if (cachedResult !== null) {\n    return cachedResult;\n  }\n\n  try {\n    const platformName = platform();\n    cachedResult = platformName === 'android' || platformName === 'ios';\n    return cachedResult;\n  } catch (error) {\n    console.error('Error detecting platform:', error);\n    // 如果 Tauri API 失败，尝试使用 user agent 检测\n    if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {\n      const userAgent = navigator.userAgent.toLowerCase();\n      cachedResult = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);\n      return cachedResult;\n    }\n    cachedResult = false;\n    return false;\n  }\n}\n\n// 检查是否在 Tauri 环境中运行\nexport function checkIsTauri(): boolean {\n  // 如果已经检测过，直接返回缓存结果\n  if (cachedTauriResult !== null) {\n    return cachedTauriResult;\n  }\n\n  try {\n    // 尝试调用 Tauri API，如果成功则说明在 Tauri 环境中\n    platform();\n    cachedTauriResult = true;\n    return true;\n  } catch {\n    cachedTauriResult = false;\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/lib/context/loader.ts",
    "content": "import { getAllMemories, updateMemoryAccess } from '@/db/memories'\nimport { fetchEmbedding } from '@/lib/ai/embedding'\n\n/**\n * 上下文结果\n */\nexport interface ContextResult {\n  preferences: string[]\n  memory: Array<{ content: string; similarity: number; id: string }>\n}\n\n/**\n * 记忆加载器 - 智能检索相关记忆\n */\nclass ContextLoader {\n  private cache: Map<string, { data: ContextResult; timestamp: number }> = new Map()\n  private cacheTimeout: number = 5 * 60 * 1000 // 5 分钟\n\n  /**\n   * 计算余弦相似度\n   */\n  private cosineSimilarity(vecA: number[], vecB: number[]): number {\n    if (vecA.length !== vecB.length) {\n      return 0\n    }\n\n    let dotProduct = 0\n    let normA = 0\n    let normB = 0\n\n    for (let i = 0; i < vecA.length; i++) {\n      dotProduct += vecA[i] * vecB[i]\n      normA += vecA[i] * vecA[i]\n      normB += vecB[i] * vecB[i]\n    }\n\n    if (normA === 0 || normB === 0) return 0\n\n    return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))\n  }\n\n  /**\n   * 获取查询的相关记忆\n   * - 偏好类记忆：始终包含\n   * - 记忆类：通过嵌入相似度匹配（阈值 0.7）\n   */\n  async getContextForQuery(query: string): Promise<ContextResult> {\n    // 检查缓存\n    const cacheKey = query.trim()\n    const cached = this.cache.get(cacheKey)\n    if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {\n      return cached.data\n    }\n\n    // 获取所有记忆\n    const allMemories = await getAllMemories()\n\n    // 分类：偏好和记忆\n    const preferences = allMemories.filter(m => m.category === 'preference')\n    const memoryList = allMemories.filter(m => m.category === 'memory')\n\n    // 偏好始终包含\n    const preferenceContents = preferences.map(m => m.content)\n\n    // 记忆需要语义匹配\n    const relevantMemory: Array<{ content: string; similarity: number; id: string }> = []\n\n    if (query && memoryList.length > 0) {\n      const queryEmbedding = await fetchEmbedding(query)\n\n      if (queryEmbedding) {\n        const MEMORY_THRESHOLD = 0.7\n\n        for (const m of memoryList) {\n          if (!m.embedding) continue\n\n          try {\n            const memoryEmbedding = JSON.parse(m.embedding) as number[]\n            const similarity = this.cosineSimilarity(queryEmbedding, memoryEmbedding)\n\n            if (similarity >= MEMORY_THRESHOLD) {\n              relevantMemory.push({\n                content: m.content,\n                similarity,\n                id: m.id\n              })\n\n              // 更新访问统计\n              await updateMemoryAccess(m.id)\n            }\n          } catch {\n            continue\n          }\n        }\n\n        // 按相似度降序排序\n        relevantMemory.sort((a, b) => b.similarity - a.similarity)\n      }\n    }\n\n    const result: ContextResult = {\n      preferences: preferenceContents,\n      memory: relevantMemory\n    }\n\n    // 缓存结果\n    this.cache.set(cacheKey, { data: result, timestamp: Date.now() })\n\n    return result\n  }\n\n  /**\n   * 格式化记忆为系统提示词格式\n   */\n  formatMemoriesForPrompt(context: ContextResult): string {\n    const parts: string[] = []\n\n    if (context.preferences.length > 0) {\n      parts.push('## 用户偏好\\n')\n      parts.push(context.preferences.map((p, i) => `${i + 1}. ${p}`).join('\\n'))\n    }\n\n    if (context.memory.length > 0) {\n      if (parts.length > 0) parts.push('\\n')\n      parts.push('## 相关记忆\\n')\n      parts.push(context.memory.map((k, i) =>\n        `${i + 1}. ${k.content}`\n      ).join('\\n'))\n    }\n\n    return parts.join('')\n  }\n\n  /**\n   * 清除缓存\n   */\n  clearCache(): void {\n    this.cache.clear()\n  }\n}\n\n// 导出单例实例\nexport const contextLoader = new ContextLoader()\nexport { ContextLoader }\n"
  },
  {
    "path": "src/lib/default-filename.ts",
    "content": "import { exists } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from './workspace'\n\n/**\n * 生成唯一的默认文件名\n * @param parentPath 父目录路径，空字符串表示根目录\n * @param baseName 基础文件名，默认为 \"Untitled\"\n * @returns 唯一的文件名（包含.md扩展名）\n */\nexport async function generateUniqueFilename(parentPath: string = '', baseName: string = 'Untitled'): Promise<string> {\n  const workspace = await getWorkspacePath()\n\n  // 构建基础文件名\n  let filename = `${baseName}.md`\n  let counter = 0\n\n  while (true) {\n    // 构建完整的相对路径\n    const fullRelativePath = parentPath ? `${parentPath}/${filename}` : filename\n    const pathOptions = await getFilePathOptions(fullRelativePath)\n\n    // 检查文件是否存在\n    let fileExists = false\n    try {\n      if (workspace.isCustom) {\n        fileExists = await exists(pathOptions.path)\n      } else {\n        fileExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch {\n      // 如果检查失败，假设文件不存在\n      fileExists = false\n    }\n\n    if (!fileExists) {\n      return filename\n    }\n\n    // 文件存在，生成下一个候选名称\n    counter++\n    filename = `${baseName} (${counter}).md`\n  }\n}\n\n/**\n * 生成复制文件的唯一名称\n * @param parentPath 父目录路径\n * @param originalName 原始文件名\n * @returns 唯一的文件名（保留原始扩展名）\n */\nexport async function generateCopyFilename(parentPath: string, originalName: string): Promise<string> {\n  const workspace = await getWorkspacePath()\n\n  // 分离文件名和扩展名\n  const lastDotIndex = originalName.lastIndexOf('.')\n  const baseName = lastDotIndex > 0 ? originalName.substring(0, lastDotIndex) : originalName\n  const extension = lastDotIndex > 0 ? originalName.substring(lastDotIndex) : ''\n\n  // 首先尝试原始名称\n  let filename = originalName\n  let counter = 0\n\n  while (true) {\n    // 构建完整的相对路径\n    const fullRelativePath = parentPath ? `${parentPath}/${filename}` : filename\n    const pathOptions = await getFilePathOptions(fullRelativePath)\n\n    // 检查文件是否存在\n    let fileExists = false\n    try {\n      if (workspace.isCustom) {\n        fileExists = await exists(pathOptions.path)\n      } else {\n        fileExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch {\n      // 如果检查失败，假设文件不存在\n      fileExists = false\n    }\n\n    if (!fileExists) {\n      return filename\n    }\n\n    // 文件存在，生成下一个候选名称\n    counter++\n    if (counter === 1) {\n      // 第一次重复，使用 \"_copy\" 后缀\n      filename = `${baseName}_copy${extension}`\n    } else {\n      // 后续重复，使用数字后缀\n      filename = `${baseName}_copy_${counter}${extension}`\n    }\n  }\n}\n\n/**\n * 生成复制文件夹的唯一名称\n * @param parentPath 父目录路径\n * @param originalName 原始文件夹名\n * @returns 唯一的文件夹名\n */\nexport async function generateCopyFoldername(parentPath: string, originalName: string): Promise<string> {\n  const workspace = await getWorkspacePath()\n\n  // 首先尝试原始名称\n  let foldername = originalName\n  let counter = 0\n\n  while (true) {\n    // 构建完整的相对路径\n    const fullRelativePath = parentPath ? `${parentPath}/${foldername}` : foldername\n    const pathOptions = await getFilePathOptions(fullRelativePath)\n\n    // 检查文件夹是否存在\n    let folderExists = false\n    try {\n      if (workspace.isCustom) {\n        folderExists = await exists(pathOptions.path)\n      } else {\n        folderExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch {\n      // 如果检查失败，假设文件夹不存在\n      folderExists = false\n    }\n\n    if (!folderExists) {\n      return foldername\n    }\n\n    // 文件夹存在，生成下一个候选名称\n    counter++\n    if (counter === 1) {\n      // 第一次重复，使用 \"_copy\" 后缀\n      foldername = `${originalName}_copy`\n    } else {\n      // 后续重复，使用数字后缀\n      foldername = `${originalName}_copy_${counter}`\n    }\n  }\n}\n\n\n"
  },
  {
    "path": "src/lib/editor-layout-styles.ts",
    "content": "export function getEditorContentContainerClass(options: {\n  centeredContent: boolean\n  isMobile: boolean\n}) {\n  if (options.isMobile) {\n    return ''\n  }\n\n  if (options.centeredContent) {\n    return 'max-w-3xl mx-auto px-4'\n  }\n\n  return 'px-10'\n}\n"
  },
  {
    "path": "src/lib/emitter.ts",
    "content": "import mitt from 'mitt'\nimport type { QuickPrompt } from '@/lib/ai/placeholder'\nimport type { OnboardingStepId } from '@/app/core/main/editor/onboarding-state'\n\n// 定义事件类型\ninterface Events {\n  'searchAndScroll': string;\n  'ai-completion-loading': boolean;\n  'auto-completion-enabled-changed': boolean;\n  'editor-input': unknown;\n  'editor:ready': unknown;\n  'editor-mode-changed': string;\n  'external-content-update': string;\n  'editor-content-from-remote': { content: string };\n  'toolbar-text-number': number;\n  'toolbar-reset-selected-text': unknown;\n  'quickRecordText': unknown;\n  'quickRecordTextHandler': { prefillText?: string } | undefined;\n  'onboarding-record-prefill-changed': { prefillText?: string } | undefined;\n  'onboarding-step-complete': { step: OnboardingStepId; filePath?: string };\n  'openWindow': unknown;\n  'immediate-pull-needed': { type: string; path: string; hash: string; filePath: string } | { type: string; filePath: string } | { filePath: string; isRemoteFile: boolean };\n  'getSettingModelList': unknown;\n  'insert-quote': {\n    quote: string;\n    fullContent: string;\n    fileName: string;\n    startLine: number;\n    endLine: number;\n    from: number;\n    to: number;\n    articlePath: string;\n  };\n  'toolbar-shortcut-image': unknown;\n  'toolbar-shortcut-file': unknown;\n  'toolbar-shortcut-todo': unknown;\n  'editor-ai-streaming': { isStreaming: boolean; targetFilePath?: string; terminate?: () => void };\n  'toolbar-shortcut-recording': unknown;\n  'toolbar-shortcut-scan': unknown;\n  'toolbar-shortcut-text': unknown;\n  'toolbar-shortcut-link': unknown;\n  'latest-commit-info': {\n    sha: string;\n    message: string;\n    author: string;\n    date: Date;\n    additions?: number;\n    deletions?: number;\n  };\n  'sync-success': unknown;\n  'sync-content-updated': { path: string; content: string };\n  'sync-push-completed': { path: string; success: boolean; sha?: string };\n  'sync-sha-mismatch': { path: string; localSha?: string; remoteSha?: string; force?: boolean };\n  'revertChat': unknown;\n  'fileSelected': {\n    name: string;\n    path: string;\n    relativePath: string;\n  };\n  'folderSelected': {\n    name: string;\n    path: string;\n    relativePath: string;\n    fileCount: number;\n    indexedCount: number;\n  };\n  'toolbar-mark': unknown;\n  'toolbar-continue': unknown;\n  'toolbar-question': unknown;\n  'toolbar-translation': unknown;\n  'toolbar-organize': unknown;\n  'screenshot-shortcut-register': unknown;\n  'text-shortcut-register': unknown;\n  'window-pin-register': unknown;\n  'link-shortcut-register': unknown;\n  'refresh-marks': unknown;\n  'quick-prompt-insert': string;\n  'quick-prompt-send': string;\n  'ai-placeholder-generated': string;\n  'ai-prompts-generated': QuickPrompt[];\n  'start-ai-streaming': {\n    originalText: string;\n    type: string;\n    position: { top: number; left: number; right: number; bottom: number };\n    controller?: AbortController;\n  };\n  'update-ai-streaming-content': {\n    suggestedText: string;\n    position: { top: number; left: number; right: number; bottom: number };\n  };\n  'ai-streaming-complete': {\n    originalText: string;\n    suggestedText: string;\n    type: string;\n    position: { top: number; left: number; right: number; bottom: number };\n    generatedRange?: { from: number; to: number };\n  } | undefined;\n  'show-ai-suggestion': {\n    originalText: string;\n    suggestedText: string;\n    type: string;\n    position: { top: number; left: number; right: number; bottom: number };\n    generatedRange?: { from: number; to: number };\n  };\n  'abort-ai-streaming': void;\n  // Agent 编辑器工具事件 - 内联定义避免重复\n  'editor-get-selection': { resolve: (data: { text: string; from: number; to: number; html?: string; startLine?: number; endLine?: number }) => void };\n  'editor-get-content': { resolve: (data: { markdown: string; html?: string; text: string; wordCount: number; charCount: number; totalLines?: number; numberedLines?: string; version: number }) => void };\n  'editor-insert': { content: string; resolve: (result: { success: boolean; insertedLength: number; newCursorPosition?: number }) => void };\n  'editor-undo': void;\n  'editor-redo': void;\n  'editor-can-undo-redo': { resolve: (can: { undo: boolean; redo: boolean }) => void };\n  'editor-undo-redo-changed': { undo: boolean; redo: boolean };\n  'editor-replace': {\n    content?: string;\n    range?: { from: number; to: number };\n    searchContent?: string;\n    occurrence?: number;\n    startLine?: number;\n    endLine?: number;\n    expectedVersion?: number;\n    resolve: (result: { success: boolean; insertedLength: number; message?: string; error?: string; newCursorPosition?: number; versionMismatch?: boolean }) => void;\n  };\n  [key: string]: unknown;\n  [key: symbol]: unknown;\n}\n\nconst emitter = mitt<Events>()\n\nexport type { Events }\nexport default emitter;\n"
  },
  {
    "path": "src/lib/event-report.ts",
    "content": "/**\n * 事件上报工具函数\n * 用于向 toolsetlink API 上报应用事件\n */\n\nimport CryptoJS from 'crypto-js'\nimport { arch, platform } from '@tauri-apps/plugin-os'\nimport { fetch as tauriFetch } from '@tauri-apps/plugin-http'\nimport { getVersion } from '@tauri-apps/api/app'\nimport { invoke } from '@tauri-apps/api/core'\n\n// 配置常量\nconst API_CONFIG = {\n  baseURL: 'https://api.upgrade.toolsetlink.com',\n  accessKey: 'wHi8Tkuc5i6v1UCAuVk48A',\n  secretKey: 'eg4upYo7ruJgaDVOtlHJGj4lyzG4Oh9IpLGwOc6Oehw',\n  appKey: 'tyEi-iLVFxnRhGc9c_xApw',\n}\n\n// 事件类型枚举\nexport enum EventType {\n  APP_START = 'app_start',\n  APP_UPGRADE_DOWNLOAD = 'app_upgrade_download',\n  APP_UPGRADE_UPGRADE = 'app_upgrade_upgrade',\n}\n\n// 事件数据接口\nexport interface AppStartEventData {\n  launchTime: string // RFC3339格式\n  versionCode: number\n  devModelKey?: string\n  devKey?: string\n  target?: string\n  arch?: string\n}\n\nexport interface AppUpgradeDownloadEventData {\n  downloadVersionCode: number\n  code: number // 0: 成功, 1: 失败\n  versionCode: number\n  devModelKey?: string\n  devKey?: string\n  target?: string\n  arch?: string\n}\n\nexport interface AppUpgradeUpgradeEventData {\n  upgradeVersionCode: number\n  code: number // 0: 成功, 1: 失败\n  versionCode: number\n  devModelKey?: string\n  devKey?: string\n  target?: string\n  arch?: string\n}\n\nexport type EventData = AppStartEventData | AppUpgradeDownloadEventData | AppUpgradeUpgradeEventData\n\n// 请求体接口\ninterface ReportRequestBody {\n  eventType: EventType\n  appKey: string\n  timestamp: string\n  eventData: EventData\n}\n\n/**\n * 生成 RFC3339 格式的时间戳\n * 使用 UTC 时间，避免时区问题\n */\nfunction generateRFC3339Timestamp(): string {\n  const now = new Date()\n  return now.toISOString()\n}\n\n/**\n * 生成随机 Nonce（至少16位）\n */\nfunction generateNonce(): string {\n  return Array.from({ length: 16 }, () => \n    Math.floor(Math.random() * 16).toString(16)\n  ).join('')\n}\n\n/**\n * 生成请求签名\n * 签名规则：MD5(body=${body}&nonce=${nonce}&secretKey=${secretKey}&timestamp=${timestamp}&url=${url})\n */\nfunction generateSignature(\n  body: string,\n  nonce: string,\n  timestamp: string,\n  url: string,\n  secretKey: string\n): string {\n  const signStr = `body=${body}&nonce=${nonce}&secretKey=${secretKey}&timestamp=${timestamp}&url=${url}`\n  return CryptoJS.MD5(signStr).toString()\n}\n\n/**\n * 获取当前应用版本号\n * 从运行时获取版本号，转换为数字格式\n * 例如: \"0.22.2\" -> 22002, \"1.22.2\" -> 1022002\n * 每个点分隔的数字占3位，1000进一位\n */\nasync function getVersionCode(): Promise<number> {\n  try {\n    // 从运行时获取版本号\n    const version = await getVersion()\n    const versionParts = version.split('.')\n    \n    // 确保有3个部分，不足的补0\n    const major = parseInt(versionParts[0] || '0', 10)\n    const minor = parseInt(versionParts[1] || '0', 10)\n    const patch = parseInt(versionParts[2] || '0', 10)\n    \n    // 转换为数字: major * 1000000 + minor * 1000 + patch\n    return major * 1000000 + minor * 1000 + patch\n  } catch (error) {\n    console.error('Failed to get version code:', error)\n    return 1\n  }\n}\n\n/**\n * 获取设备唯一标识\n * - 桌面端：使用硬件唯一标识（machine-uid）\n * - 移动端：使用 UUID 并持久化存储（应用卸载后会重置）\n */\nasync function getDeviceId(): Promise<string | undefined> {\n  try {\n    const deviceId = await invoke<string>('get_device_id')\n    return deviceId\n  } catch (error) {\n    console.error('Failed to get device ID:', error)\n    return undefined\n  }\n}\n\n/**\n * 获取设备信息\n */\nasync function getDeviceInfo() {\n  try {\n    const targetPlatform = await platform()\n    const archInfo = await arch()\n    const deviceId = await getDeviceId()\n    \n    return {\n      target: targetPlatform,\n      arch: archInfo,\n      devKey: deviceId,\n    }\n  } catch (error) {\n    console.error('Failed to get device info:', error)\n    return {\n      target: undefined,\n      arch: undefined,\n      devKey: undefined,\n    }\n  }\n}\n\n/**\n * 上报事件\n */\nexport async function reportEvent(\n  eventType: EventType,\n  eventData: EventData\n): Promise<boolean> {\n  try {\n    const timestamp = generateRFC3339Timestamp()\n    const nonce = generateNonce()\n    const url = '/v1/app/report'\n    \n    const requestBody: ReportRequestBody = {\n      eventType,\n      appKey: API_CONFIG.appKey,\n      timestamp,\n      eventData,\n    }\n    \n    const bodyString = JSON.stringify(requestBody)\n    const signature = generateSignature(\n      bodyString,\n      nonce,\n      timestamp,\n      url,\n      API_CONFIG.secretKey\n    )\n    \n    const response = await tauriFetch(`${API_CONFIG.baseURL}${url}`, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        'X-Timestamp': timestamp,\n        'X-Nonce': nonce,\n        'X-AccessKey': API_CONFIG.accessKey,\n        'X-Signature': signature,\n      },\n      body: bodyString,\n    })\n    \n    const result = await response.json()\n\n    if (response.ok && result.code === 0) {\n      return true\n    } else {\n      console.error('Failed to report event:', result)\n      return false\n    }\n  } catch (error) {\n    console.error('Error reporting event:', error)\n    return false\n  }\n}\n\n/**\n * 上报应用启动事件\n */\nexport async function reportAppStart(): Promise<boolean> {\n  try {\n    const versionCode = await getVersionCode()\n    const deviceInfo = await getDeviceInfo()\n    const launchTime = generateRFC3339Timestamp()\n    \n    const eventData: AppStartEventData = {\n      launchTime,\n      versionCode,\n      devKey: deviceInfo.devKey,\n      target: deviceInfo.target,\n      arch: deviceInfo.arch,\n    }\n    \n    return await reportEvent(EventType.APP_START, eventData)\n  } catch (error) {\n    console.error('Failed to report app start:', error)\n    return false\n  }\n}\n\n/**\n * 上报应用升级下载事件\n */\nexport async function reportAppUpgradeDownload(\n  downloadVersionCode: number,\n  code: number\n): Promise<boolean> {\n  try {\n    const versionCode = await getVersionCode()\n    const deviceInfo = await getDeviceInfo()\n    \n    const eventData: AppUpgradeDownloadEventData = {\n      downloadVersionCode,\n      code,\n      versionCode,\n      devKey: deviceInfo.devKey,\n      target: deviceInfo.target,\n      arch: deviceInfo.arch,\n    }\n    \n    return await reportEvent(EventType.APP_UPGRADE_DOWNLOAD, eventData)\n  } catch (error) {\n    console.error('Failed to report app upgrade download:', error)\n    return false\n  }\n}\n\n/**\n * 上报应用升级事件\n */\nexport async function reportAppUpgradeUpgrade(\n  upgradeVersionCode: number,\n  code: number\n): Promise<boolean> {\n  try {\n    const versionCode = await getVersionCode()\n    const deviceInfo = await getDeviceInfo()\n    \n    const eventData: AppUpgradeUpgradeEventData = {\n      upgradeVersionCode,\n      code,\n      versionCode,\n      devKey: deviceInfo.devKey,\n      target: deviceInfo.target,\n      arch: deviceInfo.arch,\n    }\n    \n    return await reportEvent(EventType.APP_UPGRADE_UPGRADE, eventData)\n  } catch (error) {\n    console.error('Failed to report app upgrade:', error)\n    return false\n  }\n}\n"
  },
  {
    "path": "src/lib/files.ts",
    "content": "import { readDir, BaseDirectory, DirEntry } from \"@tauri-apps/plugin-fs\";\nimport { getFilePathOptions, getWorkspacePath } from \"./workspace\";\nimport { join } from \"@tauri-apps/api/path\";\n\nexport interface MarkdownFile {\n  name: string;\n  path: string;\n  relativePath: string;\n  modifiedAt?: Date;\n  /** 文件元数据（仅在 includeMetadata=true 时返回） */\n  metadata?: {\n    size?: number;           // 文件大小（字节）\n    modifiedAt?: Date;       // 最后修改时间\n    createdAt?: Date;        // 创建时间\n    accessedAt?: Date;       // 最后访问时间\n    isReadOnly?: boolean;    // 是否只读\n  };\n}\n\n// 文件夹关联接口\nexport interface LinkedFolder {\n  name: string;           // 文件夹名称\n  path: string;           // 完整路径\n  relativePath: string;   // 相对路径\n  fileCount: number;      // 包含的markdown文件数量\n  indexedCount: number;   // 已索引的文件数量\n}\n\n// 统一的关联资源类型\nexport type LinkedResource = MarkdownFile | LinkedFolder;\n\n// 类型守卫：判断是否为文件夹\nexport function isLinkedFolder(resource: LinkedResource): resource is LinkedFolder {\n  return 'fileCount' in resource;\n}\n\n// 收集文件夹下的所有 Markdown 文件\nexport async function collectMarkdownFiles(folderPath: string): Promise<Array<{path: string, name: string}>> {\n  const files: Array<{path: string, name: string}> = [];\n  \n  const processDirectory = async (dirPath: string) => {\n    try {\n      const workspace = await getWorkspacePath();\n      const pathOptions = await getFilePathOptions(dirPath);\n      \n      let entries;\n      if (workspace.isCustom) {\n        entries = await readDir(pathOptions.path);\n      } else {\n        entries = await readDir(pathOptions.path, { baseDir: pathOptions.baseDir });\n      }\n      \n      for (const entry of entries) {\n        const entryPath = dirPath ? `${dirPath}/${entry.name}` : entry.name;\n        \n        // 过滤隐藏文件夹\n        if (entry.name.startsWith('.')) {\n          continue;\n        }\n        \n        if (entry.isDirectory) {\n          // 递归处理子目录\n          await processDirectory(entryPath);\n        } else if (entry.name.endsWith('.md')) {\n          // 添加 Markdown 文件\n          files.push({\n            path: entryPath,\n            name: entry.name\n          });\n        }\n      }\n    } catch (error) {\n      console.error(`读取目录 ${dirPath} 失败:`, error);\n    }\n  };\n  \n  await processDirectory(folderPath);\n  return files;\n}\n\n/**\n * 获取工作区中所有Markdown文件（平铺所有文件夹）\n * @param includeMetadata 是否包含文件元数据（如修改时间），默认 false\n */\nexport async function getAllMarkdownFiles(includeMetadata: boolean = false): Promise<MarkdownFile[]> {\n  const workspace = await getWorkspacePath();\n\n\n  const files: MarkdownFile[] = [];\n\n  // 递归处理目录的辅助函数\n  async function processDirectory(dirPath: string, useCustomPath: boolean, relativePath: string = \"\", depth: number = 0): Promise<void> {\n    let entries: DirEntry[];\n\n    try {\n      if (useCustomPath) {\n        entries = await readDir(dirPath);\n      } else {\n        entries = await readDir(dirPath, { baseDir: BaseDirectory.AppData });\n      }\n\n      for (const entry of entries) {\n        // 跳过隐藏文件和文件夹\n        if (entry.name === '.DS_Store' || entry.name.startsWith('.')) {\n          continue;\n        }\n\n        const currentRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;\n\n        if (entry.isDirectory) {\n          // 递归处理子目录\n          const childPath = await join(dirPath, entry.name);\n          await processDirectory(childPath, useCustomPath, currentRelativePath, depth + 1);\n        } else if (entry.name.endsWith('.md')) {\n          // 添加Markdown文件\n          const fullPath = useCustomPath\n            ? await join(dirPath, entry.name)\n            : currentRelativePath;\n\n          const fileInfo: MarkdownFile = {\n            name: entry.name,\n            path: fullPath,\n            relativePath: currentRelativePath\n          };\n\n          // 如果需要元数据，获取文件完整元数据\n          if (includeMetadata) {\n            try {\n              const { stat } = await import('@tauri-apps/plugin-fs');\n              // 使用 getFilePathOptions 获取正确的路径（兼容自定义工作区和默认工作区）\n              const pathOptions = await getFilePathOptions(currentRelativePath);\n              const metadata = pathOptions.baseDir\n                ? await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n                : await stat(pathOptions.path);\n\n              // 存储 modifiedAt 用于兼容\n              fileInfo.modifiedAt = metadata.mtime ?? undefined;\n\n              // 存储完整元数据\n              fileInfo.metadata = {\n                size: metadata.size,\n                modifiedAt: metadata.mtime ?? undefined,\n                createdAt: metadata.birthtime ?? undefined,\n                accessedAt: metadata.atime ?? undefined,\n                isReadOnly: metadata.readonly,\n              };\n            } catch (error) {\n              console.warn(`[getAllMarkdownFiles] 获取文件元数据失败: ${currentRelativePath}`, error);\n            }\n          }\n\n          files.push(fileInfo);\n        }\n      }\n    } catch (error) {\n      console.error(`目录处理失败`, {\n        dirPath,\n        error: String(error),\n        errorMessage: error instanceof Error ? error.message : String(error),\n      });\n    }\n  }\n\n  // 开始处理根目录\n  const rootPath = workspace.isCustom ? workspace.path : 'article';\n\n  await processDirectory(rootPath, workspace.isCustom);\n\n  return files;\n}"
  },
  {
    "path": "src/lib/folder-vector.ts",
    "content": "import { exists, readTextFile } from \"@tauri-apps/plugin-fs\";\nimport { collectMarkdownFiles } from \"@/lib/files\";\nimport { getFilePathOptions } from \"@/lib/workspace\";\nimport { checkEmbeddingModelAvailable, processMarkdownFile } from \"@/lib/rag\";\n\nexport type FolderVectorMode = 'missing' | 'recalculate';\n\ninterface CalculateFolderVectorsOptions {\n  folderPath: string;\n  mode: FolderVectorMode;\n  checkFileVectorIndexed?: (filePath: string) => Promise<boolean>;\n  setVectorCalcStatus?: (path: string, status: 'idle' | 'calculating' | 'completed') => void;\n  onProgress?: (progress: {\n    total: number;\n    processed: number;\n    failed: number;\n    currentFile: string;\n  }) => void;\n}\n\ninterface CalculateFolderVectorsResult {\n  total: number;\n  success: number;\n  failed: number;\n  skipped: number;\n  embeddingModelAvailable: boolean;\n}\n\nexport async function calculateFolderVectors({\n  folderPath,\n  mode,\n  checkFileVectorIndexed,\n  setVectorCalcStatus,\n  onProgress,\n}: CalculateFolderVectorsOptions): Promise<CalculateFolderVectorsResult> {\n  const markdownFiles = await collectMarkdownFiles(folderPath);\n\n  if (markdownFiles.length === 0) {\n    return {\n      total: 0,\n      success: 0,\n      failed: 0,\n      skipped: 0,\n      embeddingModelAvailable: true,\n    };\n  }\n\n  const embeddingModelAvailable = await checkEmbeddingModelAvailable();\n  if (!embeddingModelAvailable) {\n    return {\n      total: markdownFiles.length,\n      success: 0,\n      failed: 0,\n      skipped: 0,\n      embeddingModelAvailable: false,\n    };\n  }\n\n  let success = 0;\n  let failed = 0;\n  let skipped = 0;\n  let processed = 0;\n\n  for (const file of markdownFiles) {\n    try {\n      const hasVector = checkFileVectorIndexed\n        ? await checkFileVectorIndexed(file.path)\n        : false;\n\n      if (mode === 'missing' && hasVector) {\n        skipped++;\n        continue;\n      }\n\n      setVectorCalcStatus?.(file.path, 'calculating');\n\n      const pathOptions = await getFilePathOptions(file.path);\n      const fileExists = pathOptions.baseDir\n        ? await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        : await exists(pathOptions.path);\n\n      if (!fileExists) {\n        setVectorCalcStatus?.(file.path, 'idle');\n        failed++;\n        continue;\n      }\n\n      const content = pathOptions.baseDir\n        ? await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        : await readTextFile(pathOptions.path);\n\n      const successResult = await processMarkdownFile(file.path, content);\n      if (!successResult) {\n        setVectorCalcStatus?.(file.path, 'idle');\n        failed++;\n      } else {\n        setVectorCalcStatus?.(file.path, 'completed');\n        success++;\n      }\n    } catch (error) {\n      console.error(`计算文件 ${file.name} 向量失败:`, error);\n      setVectorCalcStatus?.(file.path, 'idle');\n      failed++;\n    } finally {\n      processed++;\n      onProgress?.({\n        total: markdownFiles.length,\n        processed,\n        failed,\n        currentFile: file.name,\n      });\n    }\n  }\n\n  return {\n    total: markdownFiles.length,\n    success,\n    failed,\n    skipped,\n    embeddingModelAvailable: true,\n  };\n}\n"
  },
  {
    "path": "src/lib/fuzzy-search.ts",
    "content": "import { invoke } from '@tauri-apps/api/core';\n\n// 匹配 Rust 类型的接口定义\nexport interface SearchItem {\n  id?: string;\n  desc?: string;\n  title?: string;\n  article?: string;\n  url?: string;\n  path?: string;\n  searchType?: string;\n  type?: string;\n  tagId?: number;\n  tagName?: string;\n  content?: string;\n  createdAt?: number;\n  score?: number;\n  matches?: MatchInfo;\n}\n\n// 匹配信息接口\nexport interface MatchInfo {\n  key: string;\n  indices: [number, number][];\n  value: string;\n}\n\n// 模糊搜索结果接口\nexport interface FuzzySearchResult {\n  item: SearchItem;\n  refIndex: number;\n  matches: MatchInfo[];\n  score: number;\n}\n\n// 模糊搜索选项接口\nexport interface FuzzySearchOptions {\n  keys: string[];\n  threshold?: number;\n  includeScore?: boolean;\n  includeMatches?: boolean;\n}\n\n// Rust 模糊搜索包装类\nexport class RustFuzzySearch {\n  private items: SearchItem[];\n  private options: FuzzySearchOptions;\n\n  // 构造函数\n  constructor(items: any[], options: Partial<FuzzySearchOptions> = {}) {\n    this.items = items;\n    this.options = {\n      keys: options.keys || [], // 确保有默认的键值\n      threshold: 0.3,\n      includeScore: true,\n      includeMatches: true,\n      ...options\n    };\n  }\n\n  // 执行模糊搜索\n  async search(query: string): Promise<FuzzySearchResult[]> {\n    if (!query) return [];\n    \n    try {\n      const rawResults = await invoke<Array<{item: SearchItem; refindex: number; score: number; matches: MatchInfo[]}>>('fuzzy_search', {\n        items: this.items,\n        query,\n        keys: this.options.keys,\n        threshold: this.options.threshold || 0.3,\n        includeScore: this.options.includeScore ?? true,\n        includeMatches: this.options.includeMatches ?? true\n      });\n      \n      return rawResults.map((result: { item: SearchItem; refindex: number; score: number; matches: MatchInfo[] }) => {\n        const item = result.item;\n        if ('search_type' in item && typeof item.search_type === 'string') {\n          item.searchType = item.search_type;\n          delete item.search_type;\n        }\n        \n        return {\n        item: result.item,\n        refIndex: result.refindex,\n        score: result.score,\n        matches: result.matches\n      };\n      });\n    } catch (error) {\n      console.error('模糊搜索出错:', error);\n      return [];\n    }\n  }\n\n  // 执行并行模糊搜索（适用于大数据集）\n  async searchParallel(query: string): Promise<FuzzySearchResult[]> {\n    if (!query) return [];\n    \n    try {\n      const rawResults = await invoke<Array<{item: SearchItem; refindex: number; score: number; matches: MatchInfo[]}>>('fuzzy_search_parallel', {\n        items: this.items,\n        query,\n        keys: this.options.keys,\n        threshold: this.options.threshold || 0.3,\n        includeScore: this.options.includeScore ?? true,\n        includeMatches: this.options.includeMatches ?? true\n      });\n\n      return rawResults.map((result: { item: SearchItem; refindex: number; score: number; matches: MatchInfo[] }) => {\n        const item = result.item;\n        if ('search_type' in item && typeof item.search_type === 'string') {\n          item.searchType = item.search_type;\n          delete item.search_type;\n        }\n\n        return {\n          item: result.item,\n          refIndex: result.refindex,\n          score: result.score,\n          matches: result.matches\n        };\n      });\n    } catch (error) {\n      console.error('并行模糊搜索出错:', error);\n      return [];\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/image-handler.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { writeFile, exists, mkdir } from '@tauri-apps/plugin-fs'\nimport { dirname } from '@tauri-apps/api/path'\nimport { v4 as uuidv4 } from 'uuid'\nimport { uploadImage } from './imageHosting'\nimport { getFilePathOptions, toWorkspaceRelativePath, getWorkspacePath } from './workspace'\nimport { convertImageByWorkspace } from './utils'\n\nexport interface ImageUploadResult {\n  /** Webview 可访问的 URL（用于编辑器显示） */\n  src: string\n  /** 相对于工作区的路径（用于 Markdown 保存） */\n  relativePath: string\n  /** 是否使用了图床上传 */\n  useImageHosting: boolean\n}\n\n/**\n * 处理图片文件：上传到图床或保存到本地\n * @param file 图片文件\n * @param activeFilePath 当前编辑的文件路径（用于确定本地保存位置）\n * @returns 图片 URL 或本地路径\n */\nexport async function handleImageUpload(\n  file: File,\n  activeFilePath?: string\n): Promise<ImageUploadResult> {\n  // 检查是否配置了图床\n  const isConfigured = await isImageHostingConfigured()\n\n  // 1. 如果配置了图床，尝试上传\n  if (isConfigured) {\n    try {\n      const imageHostingUrl = await uploadImage(file)\n      if (imageHostingUrl) {\n        return {\n          src: imageHostingUrl,\n          relativePath: imageHostingUrl,\n          useImageHosting: true,\n        }\n      }\n      // 如果返回 undefined，说明上传失败（配置了图床但上传返回空）\n      // 抛出错误，不要静默失败\n      throw new Error('Image hosting upload returned empty result')\n    } catch (error) {\n      console.error('[ImageHandler] Failed to upload to image hosting:', error)\n      // 图床上传失败，抛出错误而不是回退到本地保存\n      throw error\n    }\n  }\n\n  // 2. 如果没有配置图床，保存到本地\n  if (activeFilePath) {\n    try {\n      const localPath = await saveImageLocally(file, activeFilePath)\n      // 将本地路径转换为 Webview 可访问的 URL\n      const webviewUrl = await convertImageByWorkspace(localPath)\n      return {\n        src: webviewUrl,\n        relativePath: localPath,\n        useImageHosting: false,\n      }\n    } catch (error) {\n      console.error('Failed to save image locally:', error)\n      throw error\n    }\n  }\n\n  throw new Error('No image hosting configured and no active file path for local storage')\n}\n\n/**\n * 将图片保存到与 Markdown 文件相同的目录\n * @param file 图片文件\n * @param markdownPath Markdown 文件的路径（可以是完整路径、相对路径或文件名）\n * @returns 相对于工作区的图片路径\n */\nasync function saveImageLocally(file: File, markdownPath: string): Promise<string> {\n  // 生成唯一的图片文件名\n  const ext = file.name.split('.').pop() || 'png'\n  const filename = `${uuidv4()}.${ext}`.replace(/\\s/g, '_')\n\n  // 获取工作区路径信息\n  const workspace = await getWorkspacePath()\n\n  // 检查 markdownPath 是否只包含文件名（不包含路径分隔符）\n  let markdownDir: string = ''\n\n  // 如果 markdownPath 包含路径分隔符，才解析目录\n  if (markdownPath.includes('/') || markdownPath.includes('\\\\')) {\n    if (workspace.isCustom) {\n      // 自定义工作区\n      const fullDir = await dirname(markdownPath)\n      // 提取相对于工作区的部分\n      if (fullDir.startsWith(workspace.path)) {\n        markdownDir = fullDir.substring(workspace.path.length).replace(/^\\//, '')\n      } else {\n        markdownDir = '' // 不在 workspace 内，使用根目录\n      }\n    } else {\n      // 默认工作区（AppData/article）\n      // 解析 markdown 文件路径，获取其相对于 article 的路径\n      const pathOptions = await getFilePathOptions(markdownPath)\n      // 移除 article/ 前缀获取相对路径\n      const relativeMarkdownPath = pathOptions.path.replace(/^article\\//, '')\n      markdownDir = await dirname(relativeMarkdownPath)\n    }\n  }\n  // 如果 markdownDir 是空字符串，说明是根目录\n\n  // 构建图片的相对路径\n  // 如果 markdownDir 是空字符串（根目录），图片直接保存在 images 目录\n  // 否则保存在 markdownDir/images 目录\n  const imageDir = markdownDir ? `${markdownDir}/images` : 'images'\n  const imageRelativePath = `${imageDir}/${filename}`\n\n  // 确保目录存在\n  await ensureDirectoryExists(imageDir)\n\n  // 读取并保存文件\n  const arrayBuffer = await file.arrayBuffer()\n  const uint8Array = new Uint8Array(arrayBuffer)\n\n  const pathOptions = await getFilePathOptions(imageRelativePath)\n\n  await writeFile(pathOptions.path, uint8Array, {\n    baseDir: pathOptions.baseDir,\n  })\n\n  // 返回相对于工作区的路径\n  return toWorkspaceRelativePath(imageRelativePath)\n}\n\n/**\n * 确保目录存在，如果不存在则创建\n */\nasync function ensureDirectoryExists(dirPath: string): Promise<void> {\n  try {\n    const pathOptions = await getFilePathOptions(dirPath)\n\n    // 检查目录是否已存在\n    const dirExists = await exists(pathOptions.path, {\n      baseDir: pathOptions.baseDir,\n    })\n\n    if (!dirExists) {\n      // 如果目录不存在，创建它\n      await mkdir(pathOptions.path, {\n        baseDir: pathOptions.baseDir,\n        recursive: true,\n      })\n    }\n  } catch {\n    // 目录可能不存在，但这是正常的\n  }\n}\n\n/**\n * 检查是否配置了图床\n */\nexport async function isImageHostingConfigured(): Promise<boolean> {\n  const store = await Store.load('store.json')\n  const useImageRepo = await store.get<boolean>('useImageRepo')\n  const mainImageHosting = await store.get<string>('mainImageHosting')\n\n  return !!(useImageRepo && mainImageHosting && mainImageHosting !== 'none')\n}\n\n/**\n * 将 File 对象转换为 base64\n */\nexport function fileToBase64(file: File): Promise<string> {\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader()\n    reader.readAsDataURL(file)\n    reader.onload = () => {\n      resolve(reader.result as string)\n    }\n    reader.onerror = (error) => {\n      reject(error)\n    }\n  })\n}\n"
  },
  {
    "path": "src/lib/imageHosting/github.ts",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { GithubError, GithubRepoInfo } from \"../sync/github.types\";\nimport { toast } from '@/hooks/use-toast';\nimport { v4 as uuid } from 'uuid';\nimport { fileToBase64 } from \"../sync/github\";\nimport { getImageRepoName } from \"../sync/repo-utils\";\n\n// 创建 Github 图床仓库\nexport async function createImageRepo(name: string, isPrivate?: boolean) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('githubImageAccessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('Content-Type', 'application/json');\n    \n    const requestOptions = {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({\n        name,\n        description: 'This is a NoteGen sync repository.',\n        private: isPrivate\n      }),\n      proxy\n    };\n    \n    const url = 'https://api.github.com/user/repos';\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GithubRepoInfo;\n      return data;\n    }\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return undefined;\n  }\n}\n\n// 检查 Github 仓库\nexport async function checkImageRepoState(name: string) {\n  const store = await Store.load('store.json');\n  const githubUsername = await store.get('githubImageUsername')\n  const accessToken = await store.get('githubImageAccessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  // 设置请求头\n  const headers = new Headers();\n  headers.append('Authorization', `Bearer ${accessToken}`);\n  headers.append('Accept', 'application/vnd.github+json');\n  headers.append('X-GitHub-Api-Version', '2022-11-28');\n  \n  const requestOptions = {\n    method: 'GET',\n    headers,\n    proxy\n  };\n  \n  const url = `https://api.github.com/repos/${githubUsername}/${name}`;\n  const response = await fetch(url, requestOptions);\n  \n  if (response.status >= 200 && response.status < 300) {\n    const data = await response.json();\n    return data;\n  }\n  \n  return false\n}\n\nexport async function uploadImageByGithub(file: File) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('githubImageAccessToken')\n  const username = await store.get('githubImageUsername')\n\n  if (!accessToken || !username) {\n    console.error('[GitHub Image] Missing accessToken or username')\n    throw new Error('GitHub image hosting not configured: missing accessToken or username')\n  }\n\n  const id = uuid()\n\n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    const ext = file.type.split('/')[1]\n    const filename = `${id}.${ext}`.replace(/\\s/g, '_')\n    \n    // 获取实际使用的仓库名（自定义或默认）\n    const repoName = await getImageRepoName()\n    \n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('Content-Type', 'application/json');\n\n    const content = (await fileToBase64(file)).replace('data:application/octet-stream;base64,', '')\n    \n    const requestOptions = {\n      method: 'PUT',\n      headers,\n      body: JSON.stringify({\n        message: `Upload ${filename}`,\n        content,\n        sha: '',\n      }),\n      proxy\n    };\n    \n    const url = `https://api.github.com/repos/${username}/${repoName}/contents/${filename}`;\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n\n      const store = await Store.load('store.json');\n      const jsdelivr = await store.get('jsdelivr')\n      let url = data.content.download_url\n      if (jsdelivr) {\n        await fetch(`https://purge.jsdelivr.net/gh/${username}/${repoName}@main/${data.content.name}`)\n        url = `https://cdn.jsdelivr.net/gh/${username}/${repoName}@main/${data.content.name}`\n      }\n      return url\n    }\n    \n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || 'Upload image failed'\n    };\n  } catch (error) {\n    toast({\n      title: 'Upload image failed',\n      description: (error as GithubError).message,\n      variant: 'destructive',\n    })\n    throw error  // 抛出错误，让 handleImageUpload 知道上传失败\n  }\n}\n\nexport async function getImageFiles({ path }: { path: string }) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('githubImageAccessToken')\n  if (!accessToken) return;\n  \n  const githubImageUsername = await store.get('githubImageUsername')\n  path = path.replace(/\\s/g, '_')\n  \n  // 获取实际使用的仓库名（自定义或默认）\n  const repoName = await getImageRepoName()\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('If-None-Match', '');\n    \n    const requestOptions = {\n      method: 'GET',\n      headers,\n      proxy\n    };\n    \n    const url = `https://api.github.com/repos/${githubImageUsername}/${repoName}/contents/${path}`;\n    \n    try {\n      const response = await fetch(url, requestOptions);\n      if (response.status >= 200 && response.status < 300) {\n        const data = await response.json();\n        return data;\n      }\n      return null;\n    } catch {\n      return null;\n    }\n  } catch (error) {\n    if ((error as GithubError).status !== 404) {\n      toast({\n        title: '查询失败',\n        description: (error as GithubError).message,\n        variant: 'destructive',\n      })\n    }\n  }\n}"
  },
  {
    "path": "src/lib/imageHosting/index.ts",
    "content": "import { uploadImageByGithub } from \"./github\";\nimport { uploadImageBySmms } from \"./smms\";\nimport { uploadImageByPicgo } from \"./picgo\";\nimport { uploadImageByS3 } from \"./s3\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\nexport async function uploadImage(file: File) {\n  const store = await Store.load('store.json');\n\n  // 检查是否启用了图床功能\n  const useImageRepo = await store.get<boolean>('useImageRepo')\n\n  if (!useImageRepo) {\n    return undefined\n  }\n\n  const mainImageHosting = await store.get<string>('mainImageHosting')\n\n  // 如果没有配置图床，直接返回 undefined\n  if (!mainImageHosting || mainImageHosting === 'none') {\n    return undefined\n  }\n\n  switch (mainImageHosting) {\n    case 'github':\n      return uploadImageByGithub(file)\n    case 'smms':\n      return uploadImageBySmms(file)\n    case 'picgo':\n      return uploadImageByPicgo(file)\n    case 's3':\n      return uploadImageByS3(file)\n    default:\n      return undefined\n  }\n}"
  },
  {
    "path": "src/lib/imageHosting/picgo.ts",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { appDataDir } from '@tauri-apps/api/path'\nimport { mkdir, exists, writeFile, remove } from \"@tauri-apps/plugin-fs\";\nimport { v4 as uuid } from 'uuid';\nimport { toast } from \"@/hooks/use-toast\";\n\nexport interface PicgoImageHostingSetting {\n  url: string\n  port: string\n}\n\nexport async function uploadImageByPicgo(image: File) {\n  const store = await Store.load('store.json');\n  const picgoSetting = await store.get<PicgoImageHostingSetting>('picgo')\n  if (!picgoSetting) {\n    return null\n  }\n  // 将 File 保存至缓存目录\n  const cacheDir = await appDataDir()\n  const cachePath = `${cacheDir}/picgo`\n  if (!await exists(cachePath)) {\n    await mkdir(cachePath)\n  }\n  const cacheFile = `${cachePath}/${uuid()}.png`\n  const uint8Array = new Uint8Array(await image.arrayBuffer())\n  await writeFile(cacheFile, uint8Array)\n  const body = {\n    list: [cacheFile]\n  }\n  try {\n    const response = await fetch(`${picgoSetting.url}/upload`, {\n      method: 'POST',\n      body: JSON.stringify(body)\n    })\n    const data = await response.json()\n    if (data.success) {\n      return data.result[0]\n    }\n    return null\n  } catch (error) {\n    toast({\n      title: 'Upload failed',\n      description: error instanceof Error ? error.message : 'Upload failed',\n      variant: 'destructive',\n    })\n    return null\n  } finally {\n    await remove(cacheFile)\n  }\n}\n\nexport async function checkPicgoState() {\n  const store = await Store.load('store.json');\n  const picgoSetting = await store.get<PicgoImageHostingSetting>('picgo')\n  if (!picgoSetting) {\n    return false\n  }\n  try {\n    const response = await fetch(`${picgoSetting.url}/upload`, {\n      method: 'POST',\n    })\n    await response.json()\n    return true\n  } catch {\n    return false\n  }\n}\n  "
  },
  {
    "path": "src/lib/imageHosting/s3.ts",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { toast } from '@/hooks/use-toast';\nimport { v4 as uuid } from 'uuid';\n\ninterface S3Config {\n  accessKeyId: string\n  secretAccessKey: string\n  region: string\n  bucket: string\n  endpoint?: string\n  customDomain?: string\n  pathPrefix?: string\n}\n\n// 生成 AWS 签名 V4 (使用 Web Crypto API)\nasync function generateSignature(\n  method: string,\n  url: string,\n  headers: Record<string, string>,\n  payload: BufferSource,\n  config: S3Config\n) {\n  const algorithm = 'AWS4-HMAC-SHA256';\n  const date = new Date();\n  const dateStamp = date.toISOString().slice(0, 10).replace(/-/g, '');\n  const amzDate = date.toISOString().replace(/[:\\-]|\\.\\d{3}/g, '');\n  \n  // 必须将 x-amz-date 加入 headers 参与签名\n  headers['x-amz-date'] = amzDate;\n  \n  // 创建规范请求\n  // 必须对路径进行 URI 编码，但要保留斜杠\n  const canonicalUri = new URL(url).pathname.split('/').map(encodeURIComponent).join('/');\n  const canonicalQuerystring = '';\n\n  // AWS V4 签名要求 Headers 的 Key 必须全部转为小写\n  const canonicalHeaders = Object.keys(headers)\n    .sort()\n    .map(key => `${key.toLowerCase()}:${headers[key].trim()}\\n`)\n    .join('');\n    \n  const signedHeaders = Object.keys(headers)\n    .sort()\n    .map(key => key.toLowerCase())\n    .join(';');\n  \n  // 使用 Web Crypto API 计算 SHA256\n  const payloadHash = await crypto.subtle.digest('SHA-256', payload);\n  const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('');\n  \n  const canonicalRequest = [\n    method,\n    canonicalUri,\n    canonicalQuerystring,\n    canonicalHeaders,\n    signedHeaders,\n    payloadHashHex\n  ].join('\\n');\n  \n  // 创建字符串以供签名\n  const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;\n  const stringToSign = [\n    algorithm,\n    amzDate,\n    credentialScope,\n    await sha256Hex(canonicalRequest)\n  ].join('\\n');\n  \n  // 计算签名\n  const signingKey = await getSignatureKey(config.secretAccessKey, dateStamp, config.region, 's3');\n  const signature = await hmacSha256Hex(signingKey, stringToSign);\n  \n  return {\n    authorization: `${algorithm} Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n    amzDate,\n    payloadHashHex\n  };\n}\n\n// Web Crypto API 辅助函数\nasync function sha256Hex(data: string): Promise<string> {\n  const encoder = new TextEncoder();\n  const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data));\n  return Array.from(new Uint8Array(hash))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('');\n}\n\nasync function hmacSha256(key: CryptoKey, data: string): Promise<ArrayBuffer> {\n  const encoder = new TextEncoder();\n  return await crypto.subtle.sign('HMAC', key, encoder.encode(data));\n}\n\nasync function hmacSha256Hex(key: CryptoKey, data: string): Promise<string> {\n  const signature = await hmacSha256(key, data);\n  return Array.from(new Uint8Array(signature))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('');\n}\n\nasync function getSignatureKey(key: string, dateStamp: string, regionName: string, serviceName: string): Promise<CryptoKey> {\n  const encoder = new TextEncoder();\n  \n  // 导入初始密钥\n  const kSecret = await crypto.subtle.importKey(\n    'raw',\n    encoder.encode('AWS4' + key),\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n  \n  // kDate = HMAC(\"AWS4\" + kSecret, Date)\n  const kDate = await crypto.subtle.sign('HMAC', kSecret, encoder.encode(dateStamp));\n  const kDateKey = await crypto.subtle.importKey(\n    'raw',\n    kDate,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n  \n  // kRegion = HMAC(kDate, Region)\n  const kRegion = await crypto.subtle.sign('HMAC', kDateKey, encoder.encode(regionName));\n  const kRegionKey = await crypto.subtle.importKey(\n    'raw',\n    kRegion,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n  \n  // kService = HMAC(kRegion, Service)\n  const kService = await crypto.subtle.sign('HMAC', kRegionKey, encoder.encode(serviceName));\n  const kServiceKey = await crypto.subtle.importKey(\n    'raw',\n    kService,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n  \n  // kSigning = HMAC(kService, \"aws4_request\")\n  const kSigning = await crypto.subtle.sign('HMAC', kServiceKey, encoder.encode('aws4_request'));\n  return await crypto.subtle.importKey(\n    'raw',\n    kSigning,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  );\n}\n\n// 测试 S3 连接\nexport async function testS3Connection(config: S3Config): Promise<boolean> {\n  try {\n    const store = await Store.load('store.json');\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    const endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim();\n    const bucket = config.bucket.trim();\n    \n    // 智能判断 URL 风格\n    let url = `${endpoint}/${bucket}`;\n    \n    // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化\n    // 将 https://oss-cn-beijing.aliyuncs.com/bucket 改为 https://bucket.oss-cn-beijing.aliyuncs.com\n    const isAliyun = endpoint.includes('aliyuncs.com');\n    const isAWS = endpoint.includes('amazonaws.com');\n    \n    if (isAliyun || isAWS) {\n       try {\n         const urlObj = new URL(endpoint);\n         urlObj.hostname = `${bucket}.${urlObj.hostname}`;\n         url = urlObj.toString();\n         // 移除末尾斜杠\n         if (url.endsWith('/')) url = url.slice(0, -1);\n       } catch {\n         console.warn('[S3] Failed to construct Virtual Hosted URL, falling back to Path Style');\n       }\n    }\n    \n\n    const emptyPayload = new ArrayBuffer(0);\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload);\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('');\n    \n    const headers: Record<string, string> = {\n      'Host': new URL(url).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    };\n    \n    // 使用 GET 请求代替 HEAD，以便在出错时能获取具体的 XML 错误信息\n    const method = 'GET';\n    const { authorization, amzDate } = await generateSignature(method, url, headers, emptyPayload, config);\n    \n    const requestHeaders = new Headers();\n    requestHeaders.append('Authorization', authorization);\n    // 注意：fetch 请求头的键不区分大小写，但为了与签名完全一致，建议保持一致\n    requestHeaders.append('X-Amz-Date', amzDate);\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex);\n    \n    const response = await fetch(url, {\n      method: method,\n      headers: requestHeaders,\n      proxy\n    });\n\n    if (response.status === 200) {\n        return true;\n    }\n\n    // 如果 GET (ListObjects) 失败（可能是只有写权限），尝试 PUT 一个测试文件\n    if (response.status === 403) {\n        console.warn('ListObjects (GET) failed with 403, trying PutObject to verify write permission...');\n        \n        const testKey = '.connection-test';\n        const testUrl = `${url}/${testKey}`.replace(/([^:]\\/)\\/+/g, \"$1\");\n        const testContent = new TextEncoder().encode('test');\n        \n        const putHeaders = {\n            'Host': new URL(testUrl).host,\n            'Content-Type': 'text/plain',\n            'Content-Length': testContent.byteLength.toString()\n        };\n        \n        const { authorization: authPut, amzDate: datePut, payloadHashHex: hashPut } = \n            await generateSignature('PUT', testUrl, putHeaders, testContent, config);\n            \n        const requestPutHeaders = new Headers();\n        requestPutHeaders.append('Authorization', authPut);\n        requestPutHeaders.append('X-Amz-Date', datePut);\n        requestPutHeaders.append('Content-Type', 'text/plain');\n        requestPutHeaders.append('X-Amz-Content-Sha256', hashPut);\n        \n        const putResponse = await fetch(testUrl, {\n            method: 'PUT',\n            headers: requestPutHeaders,\n            body: testContent,\n            proxy\n        });\n        \n        if (putResponse.status === 200 || putResponse.status === 204) {\n            return true;\n        } else {\n             const putErrorText = await putResponse.text();\n             console.error('PutObject also failed:', putResponse.status, putErrorText);\n        }\n    }\n\n    const errorText = await response.text();\n    console.warn('S3 Check Failed:', {\n        status: response.status,\n        statusText: response.statusText,\n        url: url,\n        headers: Object.fromEntries(response.headers.entries()),\n        errorBody: errorText || '(empty body)'\n    });\n    \n    return false;\n  } catch (error) {\n    console.error('S3 connection test failed:', error);\n    \n    // 尝试提取更有用的错误信息\n    const errorMessage = (error as Error).message || String(error);\n    if (errorMessage.includes('error sending request')) {\n       console.warn('Network Error Details: Please check your Endpoint, Region, and Proxy settings. URL might be malformed.');\n    }\n    \n    return false;\n  }\n}\n\n// 上传图片到 S3\nexport async function uploadImageByS3(file: File): Promise<string | undefined> {\n  try {\n    const store = await Store.load('store.json');\n    const config = await store.get<S3Config>('s3Config');\n    \n    if (!config) {\n      toast({\n        title: 'S3 配置错误',\n        description: '请先配置 S3 参数',\n        variant: 'destructive',\n      });\n      return undefined;\n    }\n    \n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    // 生成文件名\n    const id = uuid();\n    const ext = file.name.split('.').pop() || 'jpg';\n    const filename = `${id}.${ext}`.replace(/\\s/g, '_');\n    \n    // 处理 pathPrefix，移除末尾的斜杠以防止双斜杠问题\n    const prefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : '';\n    const key = prefix ? `${prefix}/${filename}` : filename;\n    \n    // 准备上传\n    let endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim();\n    // 移除 endpoint 末尾的斜杠\n    if (endpoint.endsWith('/')) endpoint = endpoint.slice(0, -1);\n\n    const bucket = config.bucket.trim();\n    let url = `${endpoint}/${bucket}/${key}`;\n\n    // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化\n    const isAliyun = endpoint.includes('aliyuncs.com');\n    const isAWS = endpoint.includes('amazonaws.com');\n    \n    if (isAliyun || isAWS) {\n       try {\n         const urlObj = new URL(endpoint);\n         urlObj.hostname = `${bucket}.${urlObj.hostname}`;\n         // 重新构建 URL，包含 key\n         url = `${urlObj.toString()}/${key}`;\n         // 处理可能的双斜杠\n         url = url.replace(/([^:]\\/)\\/+/g, \"$1\");\n       } catch {\n         console.warn('[S3 Upload] Failed to switch to Virtual Hosted Style');\n       }\n    }\n    // 读取文件内容\n    const arrayBuffer = await file.arrayBuffer();\n    const uint8Array = new Uint8Array(arrayBuffer);\n    \n    const headers = {\n      'Host': new URL(url).host,\n      'Content-Type': file.type || 'application/octet-stream',\n      'Content-Length': file.size.toString()\n    };\n    \n    const { authorization, amzDate, payloadHashHex } = await generateSignature('PUT', url, headers, arrayBuffer, config);\n    \n    const requestHeaders = new Headers();\n    requestHeaders.append('Authorization', authorization);\n    requestHeaders.append('X-Amz-Date', amzDate);\n    requestHeaders.append('Content-Type', file.type || 'application/octet-stream');\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex);\n    \n    const response = await fetch(url, {\n      method: 'PUT',\n      headers: requestHeaders,\n      body: uint8Array,\n      proxy\n    });\n    \n    if (response.status === 200 || response.status === 204) {\n      // 返回访问 URL\n      if (config.customDomain) {\n        const domain = config.customDomain.trim().replace(/\\/+$/, '');\n        return `${domain}/${key}`;\n      } else {\n        // 如果使用了 Virtual Hosted Style，返回优化后的 URL\n        if (isAliyun || isAWS) {\n           try {\n             const urlObj = new URL(endpoint);\n             urlObj.hostname = `${bucket}.${urlObj.hostname}`;\n             const baseUrl = urlObj.toString().replace(/\\/+$/, '');\n             return `${baseUrl}/${key}`;\n           } catch {\n             return `${endpoint}/${bucket}/${key}`;\n           }\n        }\n        return `${endpoint}/${bucket}/${key}`;\n      }\n    } else {\n      const errorText = await response.text();\n      throw new Error(`Upload failed: ${response.status} ${errorText}`);\n    }\n    \n  } catch (error) {\n    toast({\n      title: '上传失败',\n      description: (error as Error).message,\n      variant: 'destructive',\n    });\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "src/lib/imageHosting/smms.ts",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\n\nconst BASE_URL = 'https://sm.ms/api/v2'\n\nexport interface SMMSImageHostingSetting {\n  token: string\n}\n\nexport interface SMMSUserInfo {\n  disk_limit: string\n  disk_limit_raw: number\n  disk_usage: string\n  disk_usage_raw: number\n  email: string\n  email_verified: number\n  group_expire: string\n  role: string\n  username: number\n}\n\nexport async function uploadImageBySmms(file: File) {\n  const store = await Store.load('store.json');\n  const config = await store.get<SMMSImageHostingSetting>('smms')\n  if (!config) return\n  const token = config.token\n\n  const formData = new FormData()\n  formData.append('smfile', file)\n  formData.append('format', 'json')\n\n  const response = await fetch(`${BASE_URL}/upload`, {\n    method: 'POST',\n    body: formData,\n    headers: {\n      'Authorization': token,\n      'Accept': 'application/json'\n    }\n  })\n\n  const data = await response.json()\n  if (data.code === 'image_repeated') {\n    return data.images\n  } else {\n    return data.data.url\n  }\n}\n\n// 获取用户基本信息\nexport async function getUserInfo() {\n  const store = await Store.load('store.json');\n  const config = await store.get<SMMSImageHostingSetting>('smms')\n  if (!config) return\n  const token = config.token\n\n  const response = await fetch(`${BASE_URL}/profile`, {\n    method: 'POST',\n    headers: {\n      'Authorization': token,\n    }\n  })\n  const data = await response.json()\n  return data.data as SMMSUserInfo\n}\n"
  },
  {
    "path": "src/lib/infographic.ts",
    "content": "import {\n  Infographic,\n  setDefaultFont,\n  setFontExtendFactor,\n} from '@antv/infographic';\n\nsetFontExtendFactor(1.1);\nsetDefaultFont(\n  '-apple-system-font, system-ui, \"Helvetica Neue\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei UI\", \"Microsoft YaHei\", Arial, sans-serif'\n);\n\ntype InfographicThemeMode = 'dark' | 'light';\n\ninterface InfographicRenderOptions {\n  themeMode?: InfographicThemeMode;\n}\n\nconst INFOGRAPHIC_LANGUAGE = 'infographic';\nconst INFOGRAPHIC_CONTAINER_CLASS = 'infographic-diagram';\nconst INFOGRAPHIC_MIN_HEIGHT = 120;\n\nconst DATA_PROCESSED_ATTR = 'data-infographic-processed';\nconst DATA_CODE_ATTR = 'data-infographic-code';\nconst DATA_THEME_ATTR = 'data-infographic-theme';\nconst DATA_RENDER_ID_ATTR = 'data-infographic-render-id';\n\nconst toHslString = (value: string) => {\n  if (!value) return undefined;\n  const normalized = value.replace(/\\s*\\/\\s*/g, ' ').trim();\n  const parts = normalized.split(/\\s+/);\n  if (parts.length === 3) {\n    return `hsl(${parts.join(', ')})`;\n  }\n  if (parts.length === 4) {\n    return `hsla(${parts.join(', ')})`;\n  }\n  return undefined;\n};\n\nconst getThemeColors = () => {\n  const root = document.documentElement;\n  const computedStyle = getComputedStyle(root);\n  const primary = computedStyle.getPropertyValue('--primary').trim();\n  const background = computedStyle.getPropertyValue('--background').trim();\n\n  return {\n    colorPrimary: toHslString(primary),\n    colorBg: toHslString(background),\n  };\n};\n\nconst renderInfographic = async (\n  container: HTMLElement,\n  code: string,\n  options?: InfographicRenderOptions\n) => {\n  if (typeof window === 'undefined') return;\n\n  try {\n    const themeMode = options?.themeMode === 'dark' ? 'dark' : 'light';\n    const renderTheme = themeMode === 'dark' ? 'dark' : 'default';\n    const themeColors = getThemeColors();\n\n    const renderId = `${Date.now().toString(36)}-${Math.random()\n      .toString(36)\n      .slice(2, 8)}`;\n    container.setAttribute(DATA_RENDER_ID_ATTR, renderId);\n\n    const instance = new Infographic({\n      container,\n      svg: {\n        style: {\n          width: '100%',\n          height: '100%',\n          background: themeColors.colorBg || 'transparent',\n        },\n      },\n      theme: renderTheme,\n      themeConfig: {\n        colorPrimary: themeColors.colorPrimary || undefined,\n        colorBg: themeColors.colorBg,\n      },\n    });\n\n    instance.render(code);\n  } catch (error) {\n    renderInfographicError(container, error);\n  }\n};\n\nconst renderInfographicError = (container: HTMLElement, error: unknown) => {\n  const message = error instanceof Error ? error.message : String(error);\n  container.innerHTML = `\n    <div style=\"color: #b91c1c; padding: 10px; border: 1px solid #b91c1c; background: rgba(185, 28, 28, 0.08);\">\n      Infographic 渲染失败: ${message}\n    </div>\n  `;\n};\n\nconst getInfographicCode = (element: HTMLElement) => {\n  const cached = element.getAttribute(DATA_CODE_ATTR);\n  if (cached) return cached;\n  return element.textContent || '';\n};\n\nconst ensureInfographicContainer = (element: HTMLElement) => {\n  const existing = element.querySelector<HTMLElement>(\n    `.${INFOGRAPHIC_CONTAINER_CLASS}`\n  );\n  if (existing) return existing;\n\n  const container = document.createElement('div');\n  container.className = INFOGRAPHIC_CONTAINER_CLASS;\n  container.style.width = '100%';\n  container.style.minHeight = `${INFOGRAPHIC_MIN_HEIGHT}px`;\n  container.style.overflow = 'hidden';\n  container.textContent = '正在加载 Infographic...';\n\n  element.innerHTML = '';\n  element.appendChild(container);\n  return container;\n};\n\nconst getInfographicNodes = (element: HTMLElement) => {\n  const nodes = new Set<HTMLElement>();\n  if (element.classList.contains(`language-${INFOGRAPHIC_LANGUAGE}`)) {\n    nodes.add(element);\n  }\n  element\n    .querySelectorAll<HTMLElement>(`.language-${INFOGRAPHIC_LANGUAGE}`)\n    .forEach((node) => nodes.add(node));\n  return Array.from(nodes);\n};\n\nexport const infographicRenderer = {\n  language: INFOGRAPHIC_LANGUAGE,\n  render: (element: HTMLElement) => {\n    // Tiptap doesn't use this renderer, but keep for compatibility\n    const themeMode = 'light';\n    renderInfographicElements(element, { themeMode });\n  },\n};\n\nexport const renderInfographicElements = (\n  element: HTMLElement,\n  options?: InfographicRenderOptions\n) => {\n  if (typeof window === 'undefined') return;\n\n  const themeMode = options?.themeMode === 'dark' ? 'dark' : 'light';\n  const nodes = getInfographicNodes(element);\n\n  nodes.forEach((node) => {\n    const code = getInfographicCode(node).trim();\n    if (!code) return;\n\n    const previousCode = node.getAttribute(DATA_CODE_ATTR);\n    const previousTheme = node.getAttribute(DATA_THEME_ATTR);\n    const processed = node.getAttribute(DATA_PROCESSED_ATTR) === 'true';\n\n    if (processed && previousCode === code && previousTheme === themeMode) {\n      return;\n    }\n\n    node.setAttribute(DATA_CODE_ATTR, code);\n    node.setAttribute(DATA_THEME_ATTR, themeMode);\n    node.setAttribute(DATA_PROCESSED_ATTR, 'true');\n\n    const container = ensureInfographicContainer(node);\n    container.textContent = '正在加载 Infographic...';\n    void renderInfographic(container, code, { themeMode });\n  });\n};\n"
  },
  {
    "path": "src/lib/locales.ts",
    "content": "export const locales = [\n  '简体中文',\n  'English',\n  '日本語',\n  'Français', \n  '한국어', \n  'Português', \n  'বাংলা', \n  'Italiano', \n  'فارسی', \n  'Русский', \n  'Čeština',\n]"
  },
  {
    "path": "src/lib/mark-to-markdown.ts",
    "content": "import { Mark } from \"@/db/marks\";\n\n/**\n * Convert a Mark record to markdown format based on its type\n */\nexport function markToMarkdown(mark: Mark): string {\n  switch (mark.type) {\n    case 'text':\n      // Text: insert content directly\n      return mark.content || '';\n    \n    case 'image':\n      // Image: insert as markdown image with description\n      const imageDesc = mark.desc || 'image';\n      return `![${imageDesc}](${mark.url})`;\n    \n    case 'scan':\n      // Screenshot: similar to image\n      const scanDesc = mark.desc || 'screenshot';\n      return `![${scanDesc}](${mark.url})`;\n    \n    case 'link':\n      // Link: insert as markdown link with description\n      const linkDesc = mark.desc || mark.url;\n      return `[${linkDesc}](${mark.url})`;\n    \n    case 'file':\n      // File: insert file link first, then content (e.g., extracted PDF text)\n      const fileName = mark.desc || 'file';\n      const fileLink = `[${fileName}](${mark.url})`;\n      const fileContent = mark.content || '';\n      // 如果有内容，先插入文件链接，然后是内容\n      return fileContent ? `${fileLink}\\n\\n${fileContent}` : fileLink;\n    \n    case 'recording':\n      // Recording: insert content (transcription) with audio link\n      const recordingContent = mark.content || '';\n      const audioLink = mark.url ? `\\n\\n[🎵 Audio Recording](${mark.url})` : '';\n      return recordingContent + audioLink;\n    \n    default:\n      return mark.content || '';\n  }\n}\n"
  },
  {
    "path": "src/lib/markdown.ts",
    "content": "// 根据 markdown 截取标题\nexport function extractTitle(content: string) {\n  const regex = /^# (.*)/m\n  const match = content.match(regex)\n  if (match) {\n    const res = match[1]\n    return res.replace(/[^a-zA-Z0-9\\u4e00-\\u9fa5\\s]/g, '')\n  }\n  return ''\n}"
  },
  {
    "path": "src/lib/mcp/client.ts",
    "content": "import { invoke } from '@tauri-apps/api/core'\nimport { fetch as tauriFetch } from '@tauri-apps/plugin-http'\nimport type {\n  MCPServerConfig,\n  JSONRPCRequest,\n  JSONRPCResponse,\n  InitializeResult,\n  MCPTool,\n  MCPResource,\n  CallToolResult,\n} from './types'\n\n/**\n * MCP 客户端\n * 支持 stdio 和 HTTP 两种传输协议\n */\nexport class MCPClient {\n  private config: MCPServerConfig\n  private requestId = 0\n  private isInitialized = false\n  \n  constructor(config: MCPServerConfig) {\n    this.config = config\n  }\n  \n  /**\n   * 连接到 MCP 服务器\n   */\n  async connect(): Promise<void> {\n    if (this.config.type === 'stdio') {\n      await this.connectStdio()\n    } else {\n      await this.connectHttp()\n    }\n  }\n  \n  /**\n   * 连接 stdio 服务器\n   */\n  private async connectStdio(): Promise<void> {\n    try {\n      await invoke('start_mcp_stdio_server', {\n        serverId: this.config.id,\n        command: this.config.command,\n        args: this.config.args || [],\n        env: this.config.env || {},\n      })\n    } catch (error) {\n      throw new Error(`Failed to start stdio server: ${error}`)\n    }\n  }\n  \n  /**\n   * 连接 HTTP 服务器\n   */\n  private async connectHttp(): Promise<void> {\n    // HTTP 连接不需要特殊的启动过程\n    // 只需要验证 URL 是否可访问\n    if (!this.config.url) {\n      throw new Error('HTTP server URL is required')\n    }\n  }\n  \n  /**\n   * 初始化协议\n   */\n  async initialize(): Promise<InitializeResult> {\n    const response = await this.sendRequest('initialize', {\n      protocolVersion: '2024-11-05',\n      capabilities: {},\n      clientInfo: {\n        name: 'note-gen',\n        version: '1.0.0',\n      },\n    })\n    \n    this.isInitialized = true\n    return response as InitializeResult\n  }\n  \n  /**\n   * 列出可用工具\n   */\n  async listTools(): Promise<MCPTool[]> {\n    // HTTP 服务器可能不需要初始化\n    if (this.config.type === 'stdio' && !this.isInitialized) {\n      await this.initialize()\n    }\n    \n    // 尝试不同的方法名格式\n    try {\n      const response = await this.sendRequest('tools/list', {})\n      return response.tools || []\n    } catch {\n      // 如果 tools/list 不支持，尝试 listTools\n      try {\n        const response = await this.sendRequest('listTools', {})\n        return response.tools || []\n      } catch {\n        // 如果都不支持，返回空数组\n        return []\n      }\n    }\n  }\n  \n  /**\n   * 调用工具\n   */\n  async callTool(name: string, args: any = {}): Promise<CallToolResult> {\n    if (!this.isInitialized) {\n      await this.initialize()\n    }\n    \n    const response = await this.sendRequest('tools/call', {\n      name,\n      arguments: args,\n    })\n    \n    return response as CallToolResult\n  }\n  \n  /**\n   * 列出资源\n   */\n  async listResources(): Promise<MCPResource[]> {\n    if (!this.isInitialized) {\n      await this.initialize()\n    }\n    \n    const response = await this.sendRequest('resources/list', {})\n    return response.resources || []\n  }\n  \n  /**\n   * 读取资源\n   */\n  async readResource(uri: string): Promise<string> {\n    if (!this.isInitialized) {\n      await this.initialize()\n    }\n    \n    const response = await this.sendRequest('resources/read', { uri })\n    return response.contents?.[0]?.text || ''\n  }\n  \n  /**\n   * 断开连接\n   */\n  async disconnect(): Promise<void> {\n    if (this.config.type === 'stdio') {\n      try {\n        await invoke('stop_mcp_server', { serverId: this.config.id })\n      } catch {\n        // 静默处理错误\n      }\n    }\n    this.isInitialized = false\n  }\n  \n  /**\n   * 发送 JSON-RPC 请求\n   */\n  private async sendRequest(method: string, params: any): Promise<any> {\n    const request: JSONRPCRequest = {\n      jsonrpc: '2.0',\n      id: ++this.requestId,\n      method,\n      params,\n    }\n    \n    if (this.config.type === 'stdio') {\n      return this.sendStdioRequest(request)\n    } else {\n      return this.sendHttpRequest(request)\n    }\n  }\n  \n  /**\n   * 发送 stdio 请求\n   */\n  private async sendStdioRequest(request: JSONRPCRequest): Promise<any> {\n    try {\n      const responseStr = await invoke<string>('send_mcp_message', {\n        serverId: this.config.id,\n        message: JSON.stringify(request),\n      })\n      \n      const response: JSONRPCResponse = JSON.parse(responseStr)\n      \n      if (response.error) {\n        throw new Error(response.error.message)\n      }\n      \n      return response.result\n    } catch (error) {\n      throw new Error(`Stdio request failed: ${error}`)\n    }\n  }\n  \n  /**\n   * 发送 HTTP 请求\n   */\n  private async sendHttpRequest(request: JSONRPCRequest): Promise<any> {\n    if (!this.config.url) {\n      throw new Error('HTTP server URL is required')\n    }\n    \n    try {\n      // 解析自定义 headers\n      let customHeaders: Record<string, string> = {}\n      if (this.config.headers) {\n        try {\n          customHeaders = typeof this.config.headers === 'string' \n            ? JSON.parse(this.config.headers) \n            : this.config.headers\n        } catch (e) {\n          console.warn('Failed to parse custom headers:', e)\n        }\n      }\n      \n      const response = await tauriFetch(this.config.url, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n          'Accept': 'application/json, text/event-stream',\n          ...customHeaders,\n        },\n        body: JSON.stringify(request),\n      })\n      \n      if (!response.ok) {\n        const errorText = await response.text().catch(() => response.statusText)\n        throw new Error(`HTTP ${response.status}: ${errorText}`)\n      }\n      \n      // 检查响应的 Content-Type\n      const contentType = response.headers.get('content-type')\n      \n      // 如果是 SSE 流式响应，需要特殊处理\n      if (contentType?.includes('text/event-stream')) {\n        // 对于流式响应，读取第一个事件\n        const text = await response.text()\n        \n        // 解析 SSE 格式，支持多种格式：\n        // 1. event: message\\ndata: {...}\\n\\n\n        // 2. data: {...}\\n\\n\n        const lines = text.split('\\n')\n        let jsonData = ''\n        \n        for (const line of lines) {\n          if (line.startsWith('data: ')) {\n            jsonData = line.substring(6) // 移除 \"data: \" 前缀\n            break\n          }\n        }\n        \n        if (jsonData) {\n          const jsonResponse: JSONRPCResponse = JSON.parse(jsonData)\n          if (jsonResponse.error) {\n            throw new Error(jsonResponse.error.message)\n          }\n          return jsonResponse.result\n        }\n        throw new Error('Invalid SSE response format')\n      }\n      \n      // 标准 JSON 响应\n      const jsonResponse: JSONRPCResponse = await response.json()\n      \n      if (jsonResponse.error) {\n        throw new Error(jsonResponse.error.message)\n      }\n      \n      return jsonResponse.result\n    } catch (error) {\n      // 静默处理错误，不在控制台输出\n      throw error\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/mcp/index.ts",
    "content": "export * from './types'\nexport * from './client'\nexport * from './server-manager'\nexport * from './tools'\nexport * from './integration'\nexport * from './init'\n"
  },
  {
    "path": "src/lib/mcp/init.ts",
    "content": "import { mcpIntegration } from './integration'\nimport { useMcpStore } from '@/stores/mcp'\n\n/**\n * 初始化 MCP\n * 在应用启动时调用\n */\nexport async function initMcp() {\n  try {\n    // 加载 MCP 数据\n    await useMcpStore.getState().initMcpData()\n    \n    // 初始化 MCP 集成（连接启用的服务器）\n    await mcpIntegration.initialize()\n    \n    // MCP 初始化成功\n  } catch {\n    // 静默处理初始化错误\n  }\n}\n\n/**\n * 清理 MCP 资源\n * 在应用关闭时调用\n */\nexport async function cleanupMcp() {\n  try {\n    await mcpIntegration.cleanup()\n    // MCP 清理成功\n  } catch {\n    // 静默处理清理错误\n  }\n}\n"
  },
  {
    "path": "src/lib/mcp/integration.ts",
    "content": "import { mcpServerManager } from './server-manager'\nimport { useMcpStore } from '@/stores/mcp'\nimport { callTool } from './tools'\nimport type { CallToolResult } from './types'\n\n/**\n * MCP 集成模块\n * 提供统一的 MCP 功能集成接口\n */\nexport class MCPIntegration {\n  private static instance: MCPIntegration\n  \n  private constructor() {}\n  \n  static getInstance(): MCPIntegration {\n    if (!MCPIntegration.instance) {\n      MCPIntegration.instance = new MCPIntegration()\n    }\n    return MCPIntegration.instance\n  }\n  \n  /**\n   * 初始化 MCP\n   * 连接所有启用的服务器\n   */\n  async initialize(): Promise<void> {\n    const store = useMcpStore.getState()\n    await mcpServerManager.connectEnabledServers(store.servers)\n  }\n  \n  /**\n   * 处理 AI 工具调用\n   * 当 AI 决定调用工具时调用此方法\n   */\n  async handleToolCall(\n    toolName: string,\n    args: any\n  ): Promise<{\n    success: boolean\n    result?: CallToolResult\n    error?: string\n  }> {\n    const store = useMcpStore.getState()\n    \n    // 查找工具所属的服务器\n    let targetServerId: string | null = null\n    \n    for (const serverId of store.selectedServerIds) {\n      const tools = mcpServerManager.getServerTools(serverId)\n      if (tools.some(t => t.name === toolName)) {\n        targetServerId = serverId\n        break\n      }\n    }\n    \n    if (!targetServerId) {\n      return {\n        success: false,\n        error: `Tool ${toolName} not found in selected servers`,\n      }\n    }\n    \n    try {\n      const result = await callTool(targetServerId, toolName, args)\n      return {\n        success: !result.isError,\n        result,\n      }\n    } catch (error) {\n      return {\n        success: false,\n        error: error instanceof Error ? error.message : String(error),\n      }\n    }\n  }\n  \n  /**\n   * 清理资源\n   */\n  async cleanup(): Promise<void> {\n    await mcpServerManager.disconnectAll()\n  }\n}\n\n// 导出单例\nexport const mcpIntegration = MCPIntegration.getInstance()\n"
  },
  {
    "path": "src/lib/mcp/runtime-assistant.ts",
    "content": "import { invoke } from '@tauri-apps/api/core'\n\nexport type MCPRuntimeKind =\n  | 'npx'\n  | 'uvx'\n  | 'python'\n  | 'python3'\n  | 'bunx'\n  | 'unknown'\n\nexport interface MCPInstallRecipe {\n  id: string\n  title: string\n  commandPreview: string\n  postInstallHint?: string | null\n  scope: string\n  manualOnly: boolean\n}\n\nexport interface MCPRuntimeCheckResult {\n  command: string\n  installed: boolean\n  resolvedPath?: string | null\n  version?: string | null\n  error?: string | null\n}\n\nexport interface MCPRuntimeInspection {\n  launcher: string\n  kind: MCPRuntimeKind\n  checks: MCPRuntimeCheckResult[]\n  installRecipe?: MCPInstallRecipe | null\n}\n\nexport interface MCPRuntimeInstallResult {\n  recipeId: string\n  success: boolean\n  stdout: string\n  stderr: string\n  exitCode?: number | null\n}\n\nexport interface MCPCancelInstallResult {\n  recipeId: string\n  cancelled: boolean\n}\n\nexport type MCPInstallProgressStage =\n  | 'preparing'\n  | 'running'\n  | 'cancelled'\n  | 'completed'\n  | 'failed'\n\nexport interface MCPInstallProgressEvent {\n  recipeId: string\n  stage: MCPInstallProgressStage\n  stream?: string | null\n  line?: string | null\n  exitCode?: number | null\n}\n\nexport async function inspectMcpRuntime(command: string, args: string[] = []): Promise<MCPRuntimeInspection> {\n  return invoke<MCPRuntimeInspection>('inspect_mcp_runtime', { command, args })\n}\n\nexport async function installMcpRuntime(recipeId: string): Promise<MCPRuntimeInstallResult> {\n  return invoke<MCPRuntimeInstallResult>('install_mcp_runtime', { recipeId })\n}\n\nexport async function cancelMcpRuntimeInstall(recipeId: string): Promise<MCPCancelInstallResult> {\n  return invoke<MCPCancelInstallResult>('cancel_mcp_runtime_install', { recipeId })\n}\n"
  },
  {
    "path": "src/lib/mcp/server-manager.ts",
    "content": "import { MCPClient } from './client'\nimport { fetch as tauriFetch } from '@tauri-apps/plugin-http'\nimport { useMcpStore } from '@/stores/mcp'\nimport type {\n  MCPServerConfig,\n  MCPTool,\n  MCPResource,\n  CallToolResult,\n} from './types'\n\ninterface MCPBatchTestResult {\n  total: number\n  success: number\n  failed: number\n  results: Array<{\n    serverId: string\n    success: boolean\n  }>\n}\n\n/**\n * MCP 服务器管理器\n * 管理多个 MCP 服务器的连接和工具调用\n */\nexport class MCPServerManager {\n  private static instance: MCPServerManager\n  private clients: Map<string, MCPClient> = new Map()\n  \n  private constructor() {}\n  \n  static getInstance(): MCPServerManager {\n    if (!MCPServerManager.instance) {\n      MCPServerManager.instance = new MCPServerManager()\n    }\n    return MCPServerManager.instance\n  }\n  \n  /**\n   * 连接到服务器\n   */\n  async connectServer(config: MCPServerConfig): Promise<void> {\n    const store = useMcpStore.getState()\n\n    if (this.clients.has(config.id)) {\n      await this.disconnectServer(config.id)\n    }\n    \n    // 设置连接中状态\n    store.setServerState(config.id, {\n      id: config.id,\n      status: 'connecting',\n      tools: [],\n      resources: [],\n    })\n    \n    try {\n      const client = new MCPClient(config)\n      await client.connect()\n      \n      // 初始化并获取工具列表\n      await client.initialize()\n      const tools = await client.listTools()\n      \n      // 尝试获取资源列表（某些服务器可能不支持）\n      let resources: MCPResource[] = []\n      try {\n        resources = await client.listResources()\n      } catch {\n        // 静默处理，某些服务器不支持 resources\n      }\n      \n      this.clients.set(config.id, client)\n      \n      // 更新连接成功状态\n      store.setServerState(config.id, {\n        id: config.id,\n        status: 'connected',\n        tools,\n        resources,\n        connectedAt: Date.now(),\n      })\n      \n      // 更新最后连接时间\n      store.updateServer(config.id, { lastConnected: Date.now() })\n    } catch (error) {\n      // 静默处理错误，设置错误状态\n      store.setServerState(config.id, {\n        id: config.id,\n        status: 'error',\n        tools: [],\n        resources: [],\n        error: error instanceof Error ? error.message : String(error),\n      })\n      \n      throw error\n    }\n  }\n  \n  /**\n   * 断开服务器连接\n   */\n  async disconnectServer(serverId: string): Promise<void> {\n    const client = this.clients.get(serverId)\n    if (client) {\n      await client.disconnect()\n      this.clients.delete(serverId)\n    }\n    \n    const store = useMcpStore.getState()\n    store.setServerState(serverId, {\n      id: serverId,\n      status: 'disconnected',\n      tools: [],\n      resources: [],\n    })\n  }\n  \n  /**\n   * 重新连接服务器\n   */\n  async reconnectServer(config: MCPServerConfig): Promise<void> {\n    await this.disconnectServer(config.id)\n    await this.connectServer(config)\n  }\n\n  async connectEnabledServers(servers: MCPServerConfig[]): Promise<void> {\n    for (const server of servers) {\n      if (!server.enabled) {\n        continue\n      }\n\n      try {\n        await this.connectServer(server)\n      } catch (error) {\n        console.error(`Failed to connect MCP server ${server.name}:`, error)\n      }\n    }\n  }\n  \n  /**\n   * 获取服务器的所有工具\n   */\n  getServerTools(serverId: string): MCPTool[] {\n    const store = useMcpStore.getState()\n    const state = store.getServerState(serverId)\n    return state?.tools || []\n  }\n  \n  /**\n   * 获取所有已连接服务器的工具\n   */\n  getAllTools(): Map<string, MCPTool[]> {\n    const store = useMcpStore.getState()\n    const toolsMap = new Map<string, MCPTool[]>()\n    \n    for (const server of store.servers) {\n      if (server.enabled) {\n        const state = store.getServerState(server.id)\n        if (state?.status === 'connected') {\n          toolsMap.set(server.id, state.tools)\n        }\n      }\n    }\n    \n    return toolsMap\n  }\n  \n  /**\n   * 调用工具\n   */\n  async callTool(\n    serverId: string,\n    toolName: string,\n    args: any = {}\n  ): Promise<CallToolResult> {\n    const client = this.clients.get(serverId)\n    if (!client) {\n      throw new Error(`Server ${serverId} is not connected`)\n    }\n    \n    return await client.callTool(toolName, args)\n  }\n  \n  /**\n   * 获取服务器资源\n   */\n  getServerResources(serverId: string): MCPResource[] {\n    const store = useMcpStore.getState()\n    const state = store.getServerState(serverId)\n    return state?.resources || []\n  }\n  \n  /**\n   * 读取资源\n   */\n  async readResource(serverId: string, uri: string): Promise<string> {\n    const client = this.clients.get(serverId)\n    if (!client) {\n      throw new Error(`Server ${serverId} is not connected`)\n    }\n    \n    return await client.readResource(uri)\n  }\n  \n  /**\n   * 断开所有服务器\n   */\n  async disconnectAll(): Promise<void> {\n    const promises = Array.from(this.clients.keys()).map(id =>\n      this.disconnectServer(id)\n    )\n    await Promise.all(promises)\n  }\n  \n  /**\n   * 测试服务器连接\n   * 注意：测试时不会更新 store 中的服务器状态\n   */\n  async testConnection(config: MCPServerConfig): Promise<boolean> {\n    try {\n      if (config.type === 'http') {\n        // 对于 HTTP 服务器，简单测试 URL 是否可访问\n        if (!config.url) {\n          throw new Error('HTTP server URL is required')\n        }\n        \n        // 发送一个简单的 OPTIONS 请求来测试连接\n        await tauriFetch(config.url, {\n          method: 'OPTIONS',\n          headers: {\n            'Accept': 'application/json, text/event-stream',\n          },\n        })\n        \n        // 只要服务器响应了（即使是错误），就认为连接成功\n        return true\n      } else {\n        // 对于 stdio 服务器，需要实际启动和初始化\n        const testConfig: MCPServerConfig = {\n          ...config,\n          id: `mcp-test-${config.id}-${Date.now()}`,\n        }\n        const client = new MCPClient(testConfig)\n        try {\n          await client.connect()\n          await client.initialize()\n          // 测试完成后立即断开连接并清理\n          await client.disconnect()\n          return true\n        } catch (error) {\n          console.error('测试连接失败:', error)\n          // 确保清理临时客户端\n          try {\n            await client.disconnect()\n          } catch {\n            // 静默处理清理错误\n          }\n          throw error\n        }\n      }\n    } catch {\n      // 静默处理测试失败\n      return false\n    }\n  }\n\n  async testConnections(configs: MCPServerConfig[]): Promise<MCPBatchTestResult> {\n    const results = await Promise.all(\n      configs.map(async (config) => ({\n        serverId: config.id,\n        success: await this.testConnection(config),\n      }))\n    )\n\n    const success = results.filter(result => result.success).length\n\n    return {\n      total: results.length,\n      success,\n      failed: results.length - success,\n      results,\n    }\n  }\n}\n\n// 导出单例\nexport const mcpServerManager = MCPServerManager.getInstance()\n"
  },
  {
    "path": "src/lib/mcp/tools.ts",
    "content": "import { mcpServerManager } from './server-manager'\nimport { useMcpStore } from '@/stores/mcp'\nimport type { MCPTool, CallToolResult } from './types'\n\n/**\n * 获取所有选中服务器的工具\n */\nexport function getSelectedServerTools(): Array<{\n  serverId: string\n  serverName: string\n  tool: MCPTool\n}> {\n  const store = useMcpStore.getState()\n  const result: Array<{ serverId: string; serverName: string; tool: MCPTool }> = []\n  \n  for (const server of store.servers) {\n    if (store.selectedServerIds.includes(server.id)) {\n      const tools = mcpServerManager.getServerTools(server.id)\n      for (const tool of tools) {\n        result.push({\n          serverId: server.id,\n          serverName: server.name,\n          tool,\n        })\n      }\n    }\n  }\n  \n  return result\n}\n\n/**\n * 获取所有选中服务器的工具，转换为 OpenAI Function Calling 格式\n */\nexport function getOpenAIFunctions(selectedServerIds: string[]): any[] {\n  const functions: any[] = []\n  \n  for (const serverId of selectedServerIds) {\n    const tools = mcpServerManager.getServerTools(serverId)\n    \n    for (const tool of tools) {\n      // 转换为 OpenAI Function Calling 格式\n      functions.push({\n        type: 'function',\n        function: {\n          name: `${serverId}__${tool.name}`, // 使用服务器ID作为前缀避免冲突\n          description: tool.description || tool.name,\n          parameters: tool.inputSchema || {\n            type: 'object',\n            properties: {},\n            required: [],\n          },\n        },\n      })\n    }\n  }\n  \n  return functions\n}\n\n/**\n * 搜索工具\n */\nexport function searchTools(query: string): Array<{\n  serverId: string\n  serverName: string\n  tool: MCPTool\n}> {\n  const allTools = getSelectedServerTools()\n  \n  if (!query.trim()) {\n    return allTools\n  }\n  \n  const lowerQuery = query.toLowerCase()\n  return allTools.filter(\n    ({ tool }) =>\n      tool.name.toLowerCase().includes(lowerQuery) ||\n      tool.description?.toLowerCase().includes(lowerQuery)\n  )\n}\n\n/**\n * 调用工具\n */\nexport async function callTool(\n  serverId: string,\n  toolName: string,\n  args: any = {}\n): Promise<CallToolResult> {\n  return await mcpServerManager.callTool(serverId, toolName, args)\n}\n\n/**\n * 验证工具参数\n */\nexport function validateToolArgs(tool: MCPTool, args: any): {\n  valid: boolean\n  errors: string[]\n} {\n  const errors: string[] = []\n  const required = tool.inputSchema.required || []\n  \n  // 检查必需参数\n  for (const field of required) {\n    if (!(field in args)) {\n      errors.push(`Missing required parameter: ${field}`)\n    }\n  }\n  \n  // 检查参数类型（简单验证）\n  const properties = tool.inputSchema.properties || {}\n  for (const key of Object.keys(args)) {\n    if (!(key in properties)) {\n      errors.push(`Unknown parameter: ${key}`)\n    }\n  }\n  \n  return {\n    valid: errors.length === 0,\n    errors,\n  }\n}\n\n/**\n * 格式化工具调用结果\n */\nexport function formatToolResult(result: CallToolResult): string {\n  if (result.isError) {\n    return `❌ Error: ${result.content[0]?.text || 'Unknown error'}`\n  }\n  \n  const textContent = result.content\n    .filter(c => c.type === 'text')\n    .map(c => c.text)\n    .join('\\n')\n  \n  return textContent || 'Tool executed successfully'\n}\n\n/**\n * 将工具转换为 OpenAI Function Calling 格式\n */\nexport function toolToOpenAIFunction(tool: MCPTool) {\n  return {\n    type: 'function',\n    function: {\n      name: tool.name,\n      description: tool.description || '',\n      parameters: tool.inputSchema,\n    },\n  }\n}\n\n"
  },
  {
    "path": "src/lib/mcp/types.ts",
    "content": "/**\n * MCP (Model Context Protocol) 类型定义\n */\n\n// MCP 服务器配置类型\nexport type MCPServerType = 'stdio' | 'http'\n\n// MCP 服务器配置\nexport interface MCPServerConfig {\n  id: string\n  name: string\n  type: MCPServerType\n  enabled: boolean\n  \n  // stdio 配置\n  command?: string\n  args?: string[]\n  env?: Record<string, string>\n  \n  // HTTP 配置\n  url?: string\n  headers?: Record<string, string>\n  \n  // 元数据\n  createdAt: number\n  lastConnected?: number\n}\n\n// MCP 工具定义\nexport interface MCPTool {\n  name: string\n  description?: string\n  inputSchema: {\n    type: 'object'\n    properties?: Record<string, any>\n    required?: string[]\n  }\n}\n\n// MCP 资源定义\nexport interface MCPResource {\n  uri: string\n  name: string\n  description?: string\n  mimeType?: string\n}\n\n// JSON-RPC 请求\nexport interface JSONRPCRequest {\n  jsonrpc: '2.0'\n  id: string | number\n  method: string\n  params?: any\n}\n\n// JSON-RPC 响应\nexport interface JSONRPCResponse {\n  jsonrpc: '2.0'\n  id: string | number\n  result?: any\n  error?: {\n    code: number\n    message: string\n    data?: any\n  }\n}\n\n// MCP 初始化结果\nexport interface InitializeResult {\n  protocolVersion: string\n  capabilities: {\n    tools?: Record<string, any>\n    resources?: Record<string, any>\n    prompts?: Record<string, any>\n  }\n  serverInfo: {\n    name: string\n    version: string\n  }\n}\n\n// 工具调用结果\nexport interface CallToolResult {\n  content: Array<{\n    type: 'text' | 'image' | 'resource'\n    text?: string\n    data?: string\n    mimeType?: string\n  }>\n  isError?: boolean\n}\n\n// 服务器状态\nexport type ServerStatus = 'disconnected' | 'connecting' | 'connected' | 'error'\n\n// 服务器运行时状态\nexport interface MCPServerState {\n  id: string\n  status: ServerStatus\n  tools: MCPTool[]\n  resources: MCPResource[]\n  error?: string\n  connectedAt?: number\n}\n"
  },
  {
    "path": "src/lib/ocr.ts",
    "content": "import { createWorker } from 'tesseract.js';\nimport { readFile, BaseDirectory } from '@tauri-apps/plugin-fs';\nimport { Store } from '@tauri-apps/plugin-store';\n\nexport default async function ocr(path: string): Promise<string> {\n  try {\n    const stroe = await Store.load('store.json')\n    const lang = await stroe.get<string>('tesseractList')\n    const langArr = (lang as string)?.split(',') || ['eng']\n    \n    const timeoutPromise = new Promise<string>((_, reject) => {\n      setTimeout(() => reject('OCR 识别失败'), 30000)\n    })\n\n    const workerPromise = (async () => {\n      const image = await readFile(path, { baseDir: BaseDirectory.AppData });\n      const blob = new Blob([image])\n      const worker = await createWorker(langArr)\n      const ret = (await worker.recognize(blob)).data.text;\n      await worker.terminate();\n      return ret\n    })()\n\n    return await Promise.race([workerPromise, timeoutPromise])\n  } catch (error) {\n    return error as string\n  }\n}"
  },
  {
    "path": "src/lib/outline-preferences.ts",
    "content": "export type OutlinePosition = 'left' | 'right'\n\nexport const DEFAULT_OUTLINE_POSITION: OutlinePosition = 'right'\n\nexport function normalizeOutlinePosition(value: unknown): OutlinePosition {\n  return value === 'left' ? 'left' : DEFAULT_OUTLINE_POSITION\n}\n\nexport function isOutlineOnLeft(position: OutlinePosition): boolean {\n  return position === 'left'\n}\n"
  },
  {
    "path": "src/lib/outline-styles.ts",
    "content": "export function getOutlinePanelClass(position: 'left' | 'right' = 'right') {\n  return `outline-panel w-64 min-w-64 shrink-0 ${position === 'left' ? 'border-r' : 'border-l'} border-[hsl(var(--border))] bg-[hsl(var(--background))] overflow-y-auto`\n}\n\nexport function getOutlineHeadingTextClass() {\n  return 'flex-1 min-w-0 break-all whitespace-normal leading-5'\n}\n"
  },
  {
    "path": "src/lib/path.ts",
    "content": "import { DirTree } from \"@/stores/article\"\n\n// 计算父目录路径\nexport function computedParentPath(item: DirTree) {\n  let path = item.name\n  function readParentPath(item: DirTree) {\n    if (item.parent) {\n      path = item.parent.name + '/' + path\n      if (item.parent.parent) {\n        readParentPath(item.parent)\n      }\n    }\n  }\n  readParentPath(item)\n  return path\n}\n\nexport function getCurrentFolder(path: string, fileTree: DirTree[]) {\n  if (path === '') {\n    return undefined\n  }\n  let currentFolder: DirTree | undefined\n  const levels = path.split('/')\n\n  for (let index = 0; index < levels.length; index++) {\n    const level = levels[index]\n    let currentIndex = -1\n    if (index === 0) {\n      currentIndex = fileTree.findIndex(item => item.name === level)\n    } else {\n      const _index = currentFolder?.children?.findIndex(item => item.name === level)\n      currentIndex = _index === undefined ? -1 : _index\n    }\n    if (index === 0) {\n      currentFolder = fileTree[currentIndex]\n    } else {\n      currentFolder = currentFolder?.children?.[currentIndex]\n    }\n  }\n\n  return currentFolder\n}"
  },
  {
    "path": "src/lib/pdf.ts",
    "content": "import * as pdfjsLib from 'pdfjs-dist'\nimport { readFile } from '@tauri-apps/plugin-fs'\nimport { createWorker } from 'tesseract.js'\nimport { Store } from '@tauri-apps/plugin-store'\n\n// 初始化 PDF.js worker\nif (typeof window !== 'undefined') {\n  pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`\n}\n\n// 类型守卫：检查是否为 TextItem\nfunction isTextItem(item: any): item is { str: string; transform: number[] } {\n  return item && typeof item.str === 'string' && Array.isArray(item.transform)\n}\n\n/**\n * 使用 OCR 识别 PDF 页面（用于图片型 PDF）\n */\nasync function ocrPage(canvas: HTMLCanvasElement, pageNum: number): Promise<string> {\n  try {\n    const store = await Store.load('store.json')\n    const lang = await store.get<string>('tesseractList')\n    const langArr = (lang as string)?.split(',') || ['eng']\n\n    // 将 canvas 转换为图片数据\n    const blob = await new Promise<Blob>((resolve) => {\n      canvas.toBlob((b) => resolve(b!), 'image/png')\n    })\n\n    const worker = await createWorker(langArr)\n    const ret = (await worker.recognize(blob)).data.text\n    await worker.terminate()\n\n    return ret\n  } catch (error) {\n    console.error(`OCR failed for page ${pageNum}:`, error)\n    return ''\n  }\n}\n\n/**\n * 从文件路径读取 PDF 并提取文本内容（Tauri 桌面应用）\n * @param filePath PDF 文件的本地路径\n * @param onProgress 进度回调函数\n * @returns 提取的文本内容\n */\nexport async function extractTextFromPDF(\n  filePath: string,\n  onProgress?: (progress: string) => void\n): Promise<string> {\n  try {\n    // 使用 Tauri 的 readFile 读取文件为 Uint8Array\n    const fileData = await readFile(filePath)\n\n    // 加载 PDF 文档（直接传递 Uint8Array）\n    const loadingTask = pdfjsLib.getDocument({ data: fileData })\n    const pdfDocument = await loadingTask.promise\n\n    onProgress?.(`读取 PDF (${pdfDocument.numPages} 页)`)\n\n    let fullText = ''\n    let needsOCR = false\n\n    // 先尝试直接提取文本\n    for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {\n      const page = await pdfDocument.getPage(pageNum)\n      const textContent = await page.getTextContent()\n\n      const textItems = textContent.items\n\n      if (textItems.length === 0) {\n        needsOCR = true\n        break\n      }\n\n      // 检查是否真的有文本内容（过滤掉空字符串）\n      const hasRealText = textItems.some((item: any) =>\n        isTextItem(item) && item.str.trim().length > 0\n      )\n\n      if (!hasRealText) {\n        needsOCR = true\n        break\n      }\n    }\n\n    // 如果需要 OCR，使用 OCR 提取所有页面\n    if (needsOCR) {\n      onProgress?.('OCR 识别中...')\n      return await extractTextWithOCR(pdfDocument, onProgress)\n    }\n\n    // 否则使用常规文本提取\n    onProgress?.('提取文本中...')\n    for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {\n      const page = await pdfDocument.getPage(pageNum)\n      const textContent = await page.getTextContent()\n\n      const textItems = textContent.items\n\n      // 按行组织文本\n      const textByLine = new Map<number, any[]>()\n\n      for (const item of textItems) {\n        if (!isTextItem(item)) continue\n\n        const y = Math.round(item.transform[5])\n        if (!textByLine.has(y)) {\n          textByLine.set(y, [])\n        }\n        textByLine.get(y)!.push(item)\n      }\n\n      const sortedY = Array.from(textByLine.keys()).sort((a, b) => b - a)\n\n      for (const y of sortedY) {\n        const lineItems = textByLine.get(y)!\n        lineItems.sort((a, b) => a.transform[4] - b.transform[4])\n\n        const lineText = lineItems\n          .map((item: any) => item.str)\n          .join('')\n          .trim()\n\n        if (lineText) {\n          fullText += lineText + '\\n'\n        }\n      }\n\n      fullText += '\\n'\n      onProgress?.(`提取文本中 (${pageNum}/${pdfDocument.numPages})`)\n    }\n\n    const result = fullText.trim()\n    return result\n  } catch (error) {\n    console.error('PDF text extraction error:', error)\n    throw new Error('Failed to extract text from PDF')\n  }\n}\n\n/**\n * 使用 OCR 从 PDF 中提取文本（用于图片型 PDF）\n */\nasync function extractTextWithOCR(\n  pdfDocument: pdfjsLib.PDFDocumentProxy,\n  onProgress?: (progress: string) => void\n): Promise<string> {\n  let fullText = ''\n\n  for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {\n    onProgress?.(`OCR 识别中 (${pageNum}/${pdfDocument.numPages})`)\n\n    const page = await pdfDocument.getPage(pageNum)\n    const viewport = page.getViewport({ scale: 2.0 }) // 使用更高分辨率以提高 OCR 准确率\n\n    // 创建 canvas\n    const canvas = document.createElement('canvas')\n    const context = canvas.getContext('2d')!\n    canvas.height = viewport.height\n    canvas.width = viewport.width\n\n    // 渲染 PDF 页面到 canvas\n    await page.render({\n      canvasContext: context,\n      viewport: viewport\n    }).promise\n\n    // 使用 OCR 识别页面文本\n    const pageText = await ocrPage(canvas, pageNum)\n    if (pageText.trim()) {\n      fullText += pageText.trim() + '\\n\\n'\n    }\n  }\n\n  const result = fullText.trim()\n  return result\n}\n\n/**\n * 从文件对象读取 PDF 并提取文本内容（移动端使用）\n * @param file PDF 文件对象\n * @returns 提取的文本内容\n */\nexport async function extractTextFromPDFFile(file: File): Promise<string> {\n  try {\n    const arrayBuffer = await file.arrayBuffer()\n\n    // 加载 PDF 文档\n    const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })\n    const pdfDocument = await loadingTask.promise\n\n    let fullText = ''\n\n    // 遍历所有页面\n    for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {\n      const page = await pdfDocument.getPage(pageNum)\n      const textContent = await page.getTextContent()\n\n      // 提取文本并合并\n      const pageText = textContent.items\n        .map((item: any) => item.str)\n        .join(' ')\n\n      fullText += pageText + '\\n\\n'\n    }\n\n    return fullText.trim()\n  } catch (error) {\n    console.error('PDF text extraction error:', error)\n    throw new Error('Failed to extract text from PDF')\n  }\n}\n\n/**\n * 获取 PDF 文件的基本信息\n * @param filePath PDF 文件的本地路径\n * @returns PDF 文件信息（页数等）\n */\nexport async function getPDFInfo(filePath: string): Promise<{ numPages: number }> {\n  try {\n    const response = await fetch(filePath)\n    const arrayBuffer = await response.arrayBuffer()\n\n    const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })\n    const pdfDocument = await loadingTask.promise\n\n    return {\n      numPages: pdfDocument.numPages\n    }\n  } catch (error) {\n    console.error('PDF info extraction error:', error)\n    throw new Error('Failed to get PDF info')\n  }\n}\n\n/**\n * 从文件对象获取 PDF 信息\n * @param file PDF 文件对象\n * @returns PDF 文件信息（页数等）\n */\nexport async function getPDFInfoFromFile(file: File): Promise<{ numPages: number }> {\n  try {\n    const arrayBuffer = await file.arrayBuffer()\n\n    const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer })\n    const pdfDocument = await loadingTask.promise\n\n    return {\n      numPages: pdfDocument.numPages\n    }\n  } catch (error) {\n    console.error('PDF info extraction error:', error)\n    throw new Error('Failed to get PDF info')\n  }\n}\n"
  },
  {
    "path": "src/lib/rag.ts",
    "content": "import { readTextFile, readDir, BaseDirectory, DirEntry } from \"@tauri-apps/plugin-fs\";\nimport { fetchEmbedding, rerankDocuments } from \"./ai\";\nimport {\n  upsertVectorDocument,\n  deleteVectorDocumentsByFilename,\n  getSimilarDocuments,\n  getVectorDocumentsByFilename,\n  initVectorDb,\n  VectorDocument\n} from \"@/db/vector\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { BM25Document, initBM25Index, getBM25Index } from \"./bm25\";\n\n// 重新导出initVectorDb，使其可在其他模块中导入\nexport { initVectorDb };\nimport { getFilePathOptions, getWorkspacePath } from \"./workspace\";\nimport { DirTree } from \"@/stores/article\";\nimport { toast } from \"@/hooks/use-toast\";\nimport { join } from \"@tauri-apps/api/path\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { createHash } from 'crypto';\nimport { isSkillsFolder } from './skills/utils';\nimport { getVectorDocumentKey } from './vector-document-key';\n\n/**\n * 统一错误处理函数\n */\nfunction handleRAGError(error: unknown, context: string, showToast: boolean = true): void {\n  const errorMessage = error instanceof Error ? error.message : String(error);\n  console.error(`[RAG Error] ${context}:`, errorMessage);\n\n  if (showToast) {\n    toast({\n      title: 'RAG 功能错误',\n      description: `${context}: ${errorMessage}`,\n      variant: 'destructive',\n    });\n  }\n}\n\n/**\n * 生成内容哈希值，用于去重\n */\nfunction generateContentHash(content: string): string {\n  return createHash('sha256').update(content.trim()).digest('hex');\n}\n\n/**\n * 并发控制函数 - 限制同时执行的任务数量\n */\nasync function runWithConcurrencyLimit<T>(\n  tasks: (() => Promise<T>)[],\n  limit: number,\n  onProgress?: (completed: number, total: number) => void\n): Promise<T[]> {\n  const results: T[] = new Array(tasks.length);\n  const executing: Promise<void>[] = [];\n  let completed = 0;\n\n  for (const [index, task] of tasks.entries()) {\n    const promise = task()\n      .then(result => {\n        results[index] = result;\n        completed++;\n        if (onProgress) {\n          onProgress(completed, tasks.length);\n        }\n      })\n      .catch(error => {\n        results[index] = error as T;\n        completed++;\n        if (onProgress) {\n          onProgress(completed, tasks.length);\n        }\n        throw error;\n      });\n\n    executing.push(promise);\n\n    if (executing.length >= limit) {\n      await Promise.race(executing);\n      executing.splice(\n        executing.findIndex(p => p === promise),\n        1\n      );\n    }\n  }\n\n  await Promise.all(executing);\n  return results;\n}\n\n/**\n * 文本分块函数，用于将大文本分成小块\n */\nexport function chunkText(\n  text: string, \n  chunkSize: number = 1000,\n  chunkOverlap: number = 200\n): string[] {\n  const chunks: string[] = [];\n  \n  // 检查文本是否足够长，需要分块\n  if (text.length <= chunkSize) {\n    chunks.push(text);\n    return chunks;\n  }\n  \n  // 尝试在段落边界进行分块\n  const paragraphs = text.split('\\n\\n');\n  let currentChunk = '';\n  \n  for (const paragraph of paragraphs) {\n    // 如果加上当前段落后超出了块大小，则保存当前块并开始新块\n    if (currentChunk.length + paragraph.length + 2 > chunkSize) {\n      // 如果当前块非空，保存它\n      if (currentChunk.length > 0) {\n        chunks.push(currentChunk);\n        // 保留重叠部分到新块\n        const lastChunkParts = currentChunk.split('\\n\\n');\n        const overlapLength = Math.min(chunkOverlap, currentChunk.length);\n        const overlapParts = [];\n        let currentLength = 0;\n        \n        // 从后向前取段落，直到达到重叠大小\n        for (let i = lastChunkParts.length - 1; i >= 0; i--) {\n          const part = lastChunkParts[i];\n          if (currentLength + part.length + 2 <= overlapLength) {\n            overlapParts.unshift(part);\n            currentLength += part.length + 2;\n          } else {\n            break;\n          }\n        }\n        \n        currentChunk = overlapParts.join('\\n\\n');\n      }\n      \n      // 如果单个段落过长，需要强制分割\n      if (paragraph.length > chunkSize) {\n        // 先尝试按句子分割\n        const sentences = paragraph.split(/(?:\\.|\\?|\\!)\\s+/);\n        let sentenceChunk = '';\n        \n        for (const sentence of sentences) {\n          if (sentenceChunk.length + sentence.length > chunkSize) {\n            if (sentenceChunk) {\n              chunks.push(sentenceChunk);\n              // 保留重叠\n              const overlapLength = Math.min(chunkOverlap, sentenceChunk.length);\n              sentenceChunk = sentenceChunk.slice(-overlapLength);\n            }\n          }\n          \n          sentenceChunk += sentence + ' ';\n        }\n        \n        if (sentenceChunk) {\n          currentChunk += sentenceChunk;\n        }\n      } else {\n        currentChunk += paragraph + '\\n\\n';\n      }\n    } else {\n      currentChunk += paragraph + '\\n\\n';\n    }\n  }\n  \n  // 添加最后一个块\n  if (currentChunk.trim()) {\n    chunks.push(currentChunk.trim());\n  }\n  \n  return chunks;\n}\n\n/**\n * 初始化 BM25 索引\n * 从工作区的 Markdown 文件构建 BM25 索引\n */\nexport async function initBM25Search(): Promise<void> {\n  try {\n    // 收集所有 Markdown 文件内容\n    const items = await collectMarkdownContents();\n\n    // 转换为 BM25Document 格式\n    const documents: BM25Document[] = items.map(item => ({\n      id: item.id || item.title || 'unknown',\n      content: item.title + '\\n\\n' + item.article // 包含标题和内容\n    }));\n\n    // 初始化索引\n    initBM25Index(documents);\n  } catch (error) {\n    console.error('初始化 BM25 索引失败:', error);\n  }\n}\n\n/**\n * 常用虚词/停用词列表\n * 这些词在搜索时应该被过滤或降权，因为它们在文档中出现频率过高\n */\nconst STOP_WORDS = new Set([\n  // 中文虚词\n  '的', '了', '是', '在', '有', '和', '就', '不', '人', '都', '一', '一个',\n  '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看',\n  '好', '自己', '这', '那', '里', '就是', '为', '与', '之', '用', '可以',\n  '但', '而', '或', '及', '等', '对', '把', '被', '让', '给', '从', '向',\n\n  // 英文停用词\n  'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',\n  'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',\n  'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',\n  'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those'\n]);\n\n/**\n * 同义词词典\n * 用于查询转换优化，生成查询变体\n */\nconst SYNONYM_DICT: Record<string, string[]> = {\n  // AI/技术术语\n  'ai': ['人工智能', 'artificial intelligence', '机器学习', 'ml'],\n  'llm': ['大语言模型', 'large language model', '语言模型'],\n  'rag': ['检索增强生成', 'retrieval augmented generation'],\n  'agent': ['智能体', '代理', '助手'],\n  'embedding': ['嵌入', '向量', '向量化'],\n  'vector': ['向量', '矢量'],\n  'prompt': ['提示词', '提示', '指令'],\n\n  // 通用同义词\n  '如何': ['怎么', '怎样', '如何做', '方法'],\n  '怎么': ['如何', '怎样', '怎么操作'],\n  '怎样': ['如何', '怎么', '怎样做'],\n  '是什么': ['定义', '解释', '含义', '概念'],\n  '为什么': ['原因', '为何', '理由'],\n  '做什么': ['干什么', '做什么用', '作用'],\n  '使用': ['应用', '运用', '采用', '利用'],\n  '创建': ['建立', '新建', '生成', '构建'],\n  '获取': ['得到', '获得', '取得'],\n  '设置': ['配置', '设定', '修改'],\n  '问题': ['疑问', '困难', '难题'],\n  '解决': ['处理', '修复', '解答'],\n};\n\n/**\n * 检查关键词是否为停用词\n */\nfunction isStopWord(keyword: string): boolean {\n  const cleanKeyword = keyword.trim().toLowerCase();\n  return STOP_WORDS.has(cleanKeyword);\n}\n\n/**\n * 查询转换接口\n */\ninterface QueryVariant {\n  original: string;  // 原始查询\n  transformed: string; // 转换后的查询\n  source: 'original' | 'synonym';\n}\n\n/**\n * 基于同义词词典扩展查询\n * @param query 原始查询\n * @param maxVariants 最大变体数量\n * @returns 查询变体列表\n */\nfunction expandWithSynonyms(query: string, maxVariants: number = 3): QueryVariant[] {\n  const variants: QueryVariant[] = [\n    { original: query, transformed: query, source: 'original' }\n  ];\n\n  // 检查查询中的每个词是否在同义词词典中\n  const queryLower = query.toLowerCase();\n  const words = queryLower.split(/\\s+/);\n\n  for (const word of words) {\n    // 移除标点符号\n    const cleanWord = word.replace(/[^\\w\\u4e00-\\u9fa5]/g, '');\n\n    if (SYNONYM_DICT[cleanWord]) {\n      const synonyms = SYNONYM_DICT[cleanWord];\n\n      // 为每个同义词生成变体\n      for (const synonym of synonyms) {\n        if (variants.length >= maxVariants) break;\n\n        const transformed = queryLower.replace(new RegExp(cleanWord, 'gi'), synonym);\n\n        // 避免重复\n        if (!variants.some(v => v.transformed === transformed)) {\n          variants.push({\n            original: query,\n            transformed,\n            source: 'synonym'\n          });\n        }\n      }\n    }\n\n    if (variants.length >= maxVariants) break;\n  }\n\n  return variants;\n}\n\n/**\n * 转换查询（生成多个变体）\n * @param keywords 原始关键词列表\n * @param enableExpansion 是否启用查询扩展\n * @param maxVariants 每个关键词的最大变体数量\n * @returns 扩展后的关键词列表\n */\nfunction transformQueries(\n  keywords: Keyword[],\n  enableExpansion: boolean,\n  maxVariants: number\n): Keyword[] {\n  if (!enableExpansion) {\n    return keywords;\n  }\n\n  const expandedKeywords: Keyword[] = [];\n\n  for (const keyword of keywords) {\n    // 生成查询变体\n    const variants = expandWithSynonyms(keyword.text, maxVariants);\n\n    // 将变体添加到关键词列表\n    for (const variant of variants) {\n      // 避免重复\n      if (!expandedKeywords.some(k => k.text === variant.transformed)) {\n        expandedKeywords.push({\n          text: variant.transformed,\n          weight: keyword.weight // 保持原始权重\n        });\n      }\n    }\n  }\n\n  return expandedKeywords;\n}\n\n/**\n * 扩展检索结果的句子窗口\n * 为每个匹配的 chunk 获取同一文件中相邻的 chunk，提供更完整的上下文\n *\n * @param results 原始检索结果\n * @param windowSize 窗口大小（前后各取 N 个 chunk）\n * @returns 扩展后的检索结果\n */\nasync function expandWithSentenceWindow(\n  results: Array<{ id: number; filename: string; content: string; similarity?: number }>,\n  windowSize: number = 2\n): Promise<Array<{ id: number; filename: string; content: string; similarity?: number }>> {\n  // 按文件分组结果\n  const resultsByFile = new Map<string, typeof results>();\n  for (const result of results) {\n    if (!resultsByFile.has(result.filename)) {\n      resultsByFile.set(result.filename, []);\n    }\n    resultsByFile.get(result.filename)!.push(result);\n  }\n\n  const expandedResults: typeof results = [];\n\n  // 对每个文件的结果进行扩展\n  for (const [filename, fileResults] of resultsByFile.entries()) {\n    try {\n      // 获取该文件的所有向量文档（按 chunk_id 排序）\n      const allChunks = await getVectorDocumentsByFilename(filename);\n\n      // 创建 chunk_id 到文档的映射\n      const chunkMap = new Map<number, VectorDocument>();\n      for (const chunk of allChunks) {\n        chunkMap.set(chunk.chunk_id, chunk);\n      }\n\n      // 对每个结果进行窗口扩展\n      for (const result of fileResults) {\n        // 找到该结果对应的 chunk_id\n        let centerChunkId: number | undefined;\n\n        // 通过内容匹配找到 chunk_id\n        for (const [chunkId, chunk] of chunkMap.entries()) {\n          if (chunk.content === result.content) {\n            centerChunkId = chunkId;\n            break;\n          }\n        }\n\n        if (centerChunkId === undefined) {\n          // 如果找不到对应的 chunk，直接添加原结果\n          expandedResults.push(result);\n          continue;\n        }\n\n        // 获取窗口内的相邻 chunk\n        const windowContents: string[] = [];\n        for (let i = centerChunkId - windowSize; i <= centerChunkId + windowSize; i++) {\n          const chunk = chunkMap.get(i);\n          if (chunk) {\n            windowContents.push(chunk.content);\n          }\n        }\n\n        // 合并窗口内容\n        const expandedContent = windowContents.join('\\n\\n---\\n\\n');\n\n        expandedResults.push({\n          ...result,\n          content: expandedContent\n        });\n      }\n    } catch (error) {\n      console.error(`扩展文件 ${filename} 的句子窗口失败:`, error);\n      // 失败时保留原结果\n      expandedResults.push(...fileResults);\n    }\n  }\n\n  return expandedResults;\n}\n\n/**\n * BM25 搜索辅助函数\n * @param query 查询文本\n * @param limit 返回结果数量\n * @returns BM25 检索结果\n */\nasync function searchWithBM25(query: string, limit: number = 10): Promise<Array<{id: string, score: number}>> {\n  const index = getBM25Index();\n  if (!index) {\n    console.warn('BM25 索引未初始化，跳过 BM25 搜索');\n    return [];\n  }\n\n  return index.search(query, limit);\n}\n\n/**\n * 处理单个Markdown文件，计算向量并存储到数据库\n */\nexport async function processMarkdownFile(\n  filePath: string,\n  fileContent?: string\n): Promise<boolean> {\n  try {\n    // 检查文件是否在 skills 文件夹下，如果是则跳过处理\n    const pathParts = filePath.split('/');\n    if (pathParts.some(part => isSkillsFolder(part))) {\n      return false;\n    }\n\n    const workspace = await getWorkspacePath()\n    let content = ''\n    if (workspace.isCustom) {\n      content = fileContent || await readTextFile(filePath)\n    } else {\n      const { path, baseDir } = await getFilePathOptions(filePath)\n      content = fileContent || await readTextFile(path, { baseDir })\n    }\n    // 如果内容为空或只有空白字符，跳过处理\n    if (!content || content.trim().length === 0) {\n      return false;\n    }\n\n    const store = await Store.load('store.json')\n    const chunkSize = await store.get<number>('ragChunkSize');\n    const chunkOverlap = await store.get<number>('ragChunkOverlap');\n    const chunks = chunkText(content, chunkSize, chunkOverlap).filter(chunk => chunk.trim().length > 0);\n    // 如果没有有效的文本块，跳过处理\n    if (chunks.length === 0) {\n      return false;\n    }\n    const vectorDocumentKey = getVectorDocumentKey(filePath);\n    const legacyFilename = filePath.split('/').pop() || filePath;\n\n    // 先删除该文件的旧记录\n    await deleteVectorDocumentsByFilename(vectorDocumentKey);\n    if (legacyFilename !== vectorDocumentKey) {\n      await deleteVectorDocumentsByFilename(legacyFilename);\n    }\n\n    // 处理每个文本块\n    for (let i = 0; i < chunks.length; i++) {\n      const chunk = chunks[i];\n\n      // 计算嵌入向量\n      const embedding = await fetchEmbedding(chunk);\n\n      if (!embedding) {\n        console.error(`无法计算文件 ${vectorDocumentKey} 第 ${i+1} 块的向量`);\n        continue;\n      }\n\n      // 保存到数据库\n      await upsertVectorDocument({\n        filename: vectorDocumentKey,\n        chunk_id: i,\n        content: chunk,\n        embedding: JSON.stringify(embedding),\n        updated_at: Date.now()\n      });\n    }\n\n    return true;\n  } catch (error) {\n    console.error(`处理文件 ${filePath} 失败:`, error);\n    return false;\n  }\n}\n\n/**\n * 获取工作区目录树\n */\nasync function getWorkspaceFiles(): Promise<DirTree[]> {\n  const workspace = await getWorkspacePath();\n  \n  // 递归处理目录的辅助函数\n  async function processDirectory(dirPath: string, useCustomPath: boolean): Promise<DirTree[]> {\n    let entries: DirEntry[];\n    \n    if (useCustomPath) {\n      entries = await readDir(dirPath);\n    } else {\n      entries = await readDir(dirPath, { baseDir: BaseDirectory.AppData });\n    }\n    \n    const result: DirTree[] = [];\n    \n    for (const entry of entries) {\n      if (entry.name === '.DS_Store' || entry.name.startsWith('.')) continue;\n      if (!entry.isDirectory && !entry.name.endsWith('.md')) continue;\n      \n      // 创建DirTree对象\n      const item: DirTree = {\n        name: entry.name,\n        isFile: !entry.isDirectory,\n        isDirectory: entry.isDirectory,\n        isSymlink: false, // Tauri FS API不直接提供isSymlink\n        children: [],\n        isLocale: true,\n        isEditing: false\n      };\n      \n      // 如果是目录，递归读取子目录\n      if (entry.isDirectory) {\n        const childPath = await join(dirPath, entry.name);\n        // 递归处理子目录\n        item.children = await processDirectory(childPath, useCustomPath);\n        \n        // 设置父级关系\n        item.children.forEach(child => {\n          child.parent = item;\n        });\n      }\n      \n      result.push(item);\n    }\n    \n    return result;\n  }\n  \n  // 开始处理根目录\n  const rootPath = workspace.isCustom ? workspace.path : 'article';\n  return await processDirectory(rootPath, workspace.isCustom);\n}\n\n/**\n * 处理工作区中的所有Markdown文件（支持并行处理）\n */\nexport async function processAllMarkdownFiles(onProgress?: (current: number, total: number, fileName: string) => void): Promise<{\n  total: number;\n  success: number;\n  failed: number;\n  failedFiles: Array<{fileName: string, error: string}>;\n}> {\n  try {\n    // 获取工作区中的所有文件\n    const fileTree = await getWorkspaceFiles();\n\n    // 收集所有需要处理的文件\n    const filesToProcess: Array<{name: string, path: string}> = [];\n\n    async function collectFiles(tree: DirTree[]): Promise<void> {\n      for (const item of tree) {\n        if (item.isFile && item.name.endsWith('.md')) {\n          const filePath = await getFilePath(item);\n          filesToProcess.push({ name: item.name, path: filePath });\n        }\n\n        // 递归处理子目录\n        if (item.children && item.children.length > 0) {\n          await collectFiles(item.children);\n        }\n      }\n    }\n\n    await collectFiles(fileTree);\n\n    // 使用并发控制处理文件（限制并发数为 3）\n    const results = await runWithConcurrencyLimit(\n      filesToProcess.map(file => async () => {\n        try {\n          const success = await processMarkdownFile(file.path);\n          return { success, fileName: file.name, error: null };\n        } catch (error) {\n          handleRAGError(error, `处理文件 ${file.name} 失败`, false);\n          return { success: false, fileName: file.name, error: String(error) };\n        }\n      }),\n      3, // 并发限制为 3，避免过多 API 调用\n      (completed, total) => {\n        if (onProgress && completed > 0) {\n          const currentFile = filesToProcess[completed - 1]?.name || '';\n          onProgress(completed, total, currentFile);\n        }\n      }\n    );\n\n    // 统计结果\n    const failedFiles: Array<{fileName: string, error: string}> = [];\n    let success = 0;\n    let failed = 0;\n\n    for (const result of results) {\n      if (result.success) {\n        success++;\n      } else {\n        failed++;\n        if (result.error) {\n          failedFiles.push({ fileName: result.fileName, error: result.error });\n        }\n      }\n    }\n\n    return {\n      total: filesToProcess.length,\n      success,\n      failed,\n      failedFiles\n    };\n  } catch (error) {\n    handleRAGError(error, '处理工作区Markdown文件失败');\n    throw error;\n  }\n}\n\n/**\n * 根据DirTree项获取完整文件路径\n */\nasync function getFilePath(item: DirTree): Promise<string> {\n  const workspace = await getWorkspacePath();\n  let path = item.name;\n  let parent = item.parent;\n  \n  // 构建相对路径\n  while (parent) {\n    path = `${parent.name}/${path}`;\n    parent = parent.parent;\n  }\n  \n  // 转换为完整路径\n  if (workspace.isCustom) {\n    return await join(workspace.path, path);\n  } else {\n    return path; // 返回相对于AppData/article的路径\n  }\n}\n\n/**\n * 为fuzzy_search准备的搜索项结构\n */\ninterface SearchItem {\n  id?: string;\n  desc?: string;\n  title?: string;\n  article?: string;\n  url?: string;\n  search_type?: string;\n  score?: number;\n  matches?: {\n    key: string;\n    indices: [number, number][];\n    value: string;\n  }[];\n}\n\n/**\n * fuzzy_search返回的结果结构\n */\ninterface FuzzySearchResult {\n  item: SearchItem;\n  refindex: number;\n  score: number;\n  matches: {\n    key: string;\n    indices: [number, number][];\n    value: string;\n  }[];\n}\n\n/**\n * 从工作区中收集所有Markdown文件内容，用于模糊搜索\n */\nasync function collectMarkdownContents(): Promise<SearchItem[]> {\n  try {\n    // 获取工作区中的所有文件\n    const fileTree = await getWorkspaceFiles();\n    const items: SearchItem[] = [];\n    \n    // 递归处理文件树\n    async function processTree(tree: DirTree[]): Promise<void> {\n      for (const item of tree) {\n        if (item.isFile && item.name.endsWith('.md')) {\n          // 获取完整路径\n          const filePath = await getFilePath(item);\n          \n          try {\n            // 读取文件内容\n            let content = '';\n            const workspace = await getWorkspacePath();\n            if (workspace.isCustom) {\n              content = await readTextFile(filePath);\n            } else {\n              const { path, baseDir } = await getFilePathOptions(filePath);\n              content = await readTextFile(path, { baseDir });\n            }\n            \n            // 创建搜索项\n            items.push({\n              id: filePath,\n              title: item.name,\n              article: content,\n              search_type: 'markdown'\n            });\n          } catch (error) {\n            console.error(`读取文件 ${filePath} 内容失败:`, error);\n          }\n        }\n        \n        // 递归处理子目录\n        if (item.children && item.children.length > 0) {\n          await processTree(item.children);\n        }\n      }\n    }\n    \n    await processTree(fileTree);\n    return items;\n  } catch (error) {\n    console.error('收集Markdown内容失败:', error);\n    return [];\n  }\n}\n\n/**\n * 检索结果类型定义\n */\ninterface SearchResult {\n  filename: string;\n  filepath: string;\n  content: string;\n  rawScore: number;      // 原始分数（未归一化）\n  normalizedScore: number; // 归一化后的分数\n  keyword?: string;\n  type: 'fuzzy' | 'vector' | 'bm25';\n}\n\n/**\n * 关键词及其权重类型定义\n */\nexport interface Keyword {\n  text: string;\n  weight: number;\n}\n\n/**\n * RAG 来源详情类型定义\n */\nexport interface RagSource {\n  filepath: string;  // 文件的相对路径\n  filename: string;  // 文件名\n  content: string;   // 引用的文本片段\n}\n\n/**\n * 根据关键词数组获取相关上下文\n * @param keywords 关键词数组，每个元素包含关键词文本和权重\n * @returns 包含上下文文本和引用文件名的对象\n */\nexport async function getContextForQuery(keywords: Keyword[]): Promise<{\n  context: string;\n  sources: string[];\n  sourceDetails: RagSource[];\n}> {\n  try {\n    const store = await Store.load('store.json');\n    const resultCount = await store.get<number>('ragResultCount') || 5;\n    const similarityThreshold = await store.get<number>('ragSimilarityThreshold') || 0.25;\n\n    // 读取权重配置（新增配置项）\n    const fuzzyWeight = await store.get<number>('ragFuzzyWeight') ?? 0.2;\n    const vectorWeight = await store.get<number>('ragVectorWeight') ?? 0.7;\n    const bm25Weight = await store.get<number>('ragBm25Weight') ?? 0.1;\n\n    const weights = {\n      fuzzyWeight,\n      vectorWeight,\n      bm25Weight\n    };\n\n    // 存储所有检索结果（使用新的 SearchResult 类型）\n    const allResults: SearchResult[] = [];\n\n    // 如果没有关键词，返回空结果\n    if (!keywords || keywords.length === 0) {\n      return { context: '', sources: [], sourceDetails: [] };\n    }\n\n    // 读取查询扩展配置\n    const enableQueryExpansion = await store.get<boolean>('ragEnableQueryExpansion') ?? true;\n    const maxQueryVariations = await store.get<number>('ragMaxQueryVariations') ?? 3;\n\n    // 应用查询转换（生成同义词变体）\n    const expandedKeywords = transformQueries(keywords, enableQueryExpansion, maxQueryVariations);\n\n    // 将关键词按权重排序，优先考虑权重高的关键词\n    const sortedKeywords = [...expandedKeywords].sort((a, b) => b.weight - a.weight);\n\n    // 1. 使用逐个关键词进行模糊搜索找到相关文件内容\n    try {\n      // 收集所有Markdown文件内容\n      const items = await collectMarkdownContents();\n      if (items.length > 0) {\n        // 为每个关键词单独进行搜索\n        for (const keyword of sortedKeywords) {\n          // 跳过停用词的模糊搜索（这些词匹配太多低质量结果）\n          if (isStopWord(keyword.text)) {\n            continue;\n          }\n\n          // 对每个关键词调用Rust的fuzzy_search函数\n          const fuzzyResults: FuzzySearchResult[] = await invoke('fuzzy_search', {\n            items,\n            query: keyword.text,  // 单独使用每个关键词\n            keys: ['title', 'article'],\n            threshold: 0.3, // 模糊搜索阈值\n            includeScore: true,\n            includeMatches: true\n          });\n\n          // 处理模糊搜索结果\n          for (const result of fuzzyResults) {\n            if (result.score > 0) {\n              const item = result.item;\n              // 提取匹配的文本片段作为上下文\n              const articleMatches = result.matches.filter(m => m.key === 'article');\n              if (articleMatches.length > 0) {\n                // 使用匹配部分的上下文（周围大约500个字符）\n                const match = articleMatches[0];\n                const content = match.value;\n\n                // 找到第一个匹配位置的索引\n                let startIdx = 0;\n                let endIdx = content.length;\n                if (match.indices.length > 0) {\n                  const firstMatch = match.indices[0];\n                  startIdx = Math.max(0, firstMatch[0] - 250);\n                  endIdx = Math.min(content.length, firstMatch[1] + 250);\n                }\n\n                const contextSnippet = content.substring(startIdx, endIdx);\n\n                allResults.push({\n                  filename: item.title || '未命名文件',\n                  filepath: item.id || '',\n                  content: contextSnippet,\n                  rawScore: result.score * keyword.weight, // 保留原始分数\n                  normalizedScore: 0, // 稍后计算\n                  keyword: keyword.text,\n                  type: 'fuzzy'\n                });\n              }\n            }\n          }\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, '模糊搜索失败', false);\n    }\n\n    // 2. 使用向量搜索找到相关文档\n    try {\n      // 读取窗口大小配置\n      const windowSize = await store.get<number>('ragWindowSize') ?? 2;\n\n      // 为每个关键词生成向量并执行查询\n      for (const keyword of sortedKeywords) {\n        // 计算查询文本的向量\n        const queryEmbedding = await fetchEmbedding(keyword.text);\n\n        if (!queryEmbedding) {\n          continue;\n        }\n\n        // 查询最相关的文档\n        let similarDocs = await getSimilarDocuments(queryEmbedding, resultCount * 2, similarityThreshold);\n\n        if (similarDocs.length > 0) {\n          // 如果配置了重排序模型，使用它进一步优化结果\n          similarDocs = await rerankDocuments(keyword.text, similarDocs);\n\n          // 应用句子窗口扩展（获取更多候选结果用于窗口扩展）\n          const expandedDocs = await expandWithSentenceWindow(similarDocs, windowSize);\n\n          // 添加到结果集\n          for (const doc of expandedDocs) {\n            allResults.push({\n              filename: doc.filename,\n              filepath: doc.filename,\n              content: doc.content,\n              rawScore: (doc.similarity || 0) * keyword.weight, // 保留原始分数\n              normalizedScore: 0, // 稍后计算\n              keyword: keyword.text,\n              type: 'vector'\n            });\n          }\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, '向量搜索失败', false);\n    }\n\n    // 3. 使用 BM25 搜索找到相关文档\n    try {\n      // 收集所有 Markdown 文件内容用于 BM25 匹配后获取上下文\n      const items = await collectMarkdownContents();\n      const itemsMap = new Map(items.map(item => [item.id || item.title || '', item]));\n\n      // 为每个关键词执行 BM25 搜索\n      for (const keyword of sortedKeywords) {\n        const bm25Results = await searchWithBM25(keyword.text, resultCount);\n\n        for (const result of bm25Results) {\n          const item = itemsMap.get(result.id);\n          if (!item || !item.article) continue;\n\n          // 从匹配项中提取上下文（尝试找到关键词周围的内容）\n          const articleLower = item.article.toLowerCase();\n          const keywordLower = keyword.text.toLowerCase();\n          const keywordIndex = articleLower.indexOf(keywordLower);\n\n          let startIdx = 0;\n          let endIdx = item.article.length;\n\n          if (keywordIndex >= 0) {\n            startIdx = Math.max(0, keywordIndex - 250);\n            endIdx = Math.min(item.article.length, keywordIndex + keyword.text.length + 250);\n          } else {\n            // 如果没找到精确匹配，取中间部分\n            const mid = Math.floor(item.article.length / 2);\n            startIdx = Math.max(0, mid - 250);\n            endIdx = Math.min(item.article.length, mid + 250);\n          }\n\n          const contextSnippet = item.article.substring(startIdx, endIdx);\n\n          allResults.push({\n            filename: item.title || '未命名文件',\n            filepath: item.id || '',\n            content: contextSnippet,\n            rawScore: result.score * keyword.weight,\n            normalizedScore: 0,\n            keyword: keyword.text,\n            type: 'bm25'\n          });\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, 'BM25 搜索失败', false);\n    }\n\n    // 如果没有找到任何相关上下文，返回空结果\n    if (allResults.length === 0) {\n      return { context: '', sources: [], sourceDetails: [] };\n    }\n\n    // 3. 按文档合并结果，使用归一化和混合权重\n    const mergedResults = mergeResultsByDocument(allResults, weights);\n\n    // 4. 对相似内容进行合并（使用内容重叠度判断）\n    const finalUniqueResults: SearchResult[] = [];\n    const mergedIndices = new Set<number>();\n\n    for (let i = 0; i < mergedResults.length; i++) {\n      if (mergedIndices.has(i)) continue;\n\n      const current = mergedResults[i];\n      let bestScore = current.normalizedScore;\n      let bestContent = current.content;\n      const mergedKeywords: string[] = [];\n\n      if (current.keyword) {\n        mergedKeywords.push(current.keyword);\n      }\n\n      // 查找同一文件中高度重叠的内容\n      for (let j = i + 1; j < mergedResults.length; j++) {\n        if (mergedIndices.has(j)) continue;\n\n        const other = mergedResults[j];\n        if (other.filename !== current.filename) continue;\n\n        // 计算内容重叠度\n        const overlap = calculateContentOverlap(current.content, other.content);\n\n        // 如果重叠度超过 70%，认为是重复内容，合并它们\n        if (overlap > 0.7) {\n          mergedIndices.add(j);\n          // 保留分数更高的\n          if (other.normalizedScore > bestScore) {\n            bestScore = other.normalizedScore;\n            bestContent = other.content;\n          }\n          if (other.keyword && !mergedKeywords.includes(other.keyword)) {\n            mergedKeywords.push(other.keyword);\n          }\n        }\n      }\n\n      finalUniqueResults.push({\n        ...current,\n        content: bestContent,\n        normalizedScore: bestScore,\n        keyword: mergedKeywords.join(', ')\n      });\n    }\n\n    // 对所有上下文按相关性得分排序\n    finalUniqueResults.sort((a: SearchResult, b: SearchResult) => b.normalizedScore - a.normalizedScore);\n\n    // 限制结果数量\n    const finalResults = finalUniqueResults.slice(0, resultCount);\n\n    // 提取唯一的文件名\n    const sources = Array.from(new Set(finalResults.map((ctx: SearchResult) => ctx.filename)));\n\n    // 构建 sourceDetails（去重，每个文件只保留最相关的一个片段）\n    const sourceDetailsMap = new Map<string, RagSource>();\n    for (const ctx of finalResults) {\n      if (!sourceDetailsMap.has(ctx.filename)) {\n        sourceDetailsMap.set(ctx.filename, {\n          filepath: ctx.filepath,\n          filename: ctx.filename,\n          content: ctx.content\n        });\n      }\n    }\n    const sourceDetails = Array.from(sourceDetailsMap.values());\n\n    // 构建最终的上下文字符串\n    const context = finalResults.map((ctx: SearchResult) => {\n      return `文件：${ctx.filename}\n${ctx.content}\n`;\n    }).join('\\n---\\n\\n');\n\n    return { context, sources, sourceDetails };\n  } catch (error) {\n    handleRAGError(error, '获取查询上下文失败', false);\n    return { context: '', sources: [], sourceDetails: [] };\n  }\n}\n\n/**\n * 分数归一化配置\n */\ninterface NormalizationConfig {\n  minScore: number;\n  maxScore: number;\n}\n\n/**\n * 归一化分数到 [0, 1] 区间\n * @param score 原始分数\n * @param type 分数类型（不同类型使用不同的归一化策略）\n * @param allScores 同类型所有分数的数组（用于 min-max 归一化）\n */\nfunction normalizeScore(\n  score: number,\n  type: 'fuzzy' | 'vector' | 'bm25',\n  allScores: number[] = []\n): number {\n  // 如果提供了该类型的所有分数，使用 min-max 归一化\n  if (allScores.length > 1) {\n    const min = Math.min(...allScores);\n    const max = Math.max(...allScores);\n    if (max - min < 0.0001) {\n      // 所有分数几乎相同，返回 0.5\n      return 0.5;\n    }\n    return (score - min) / (max - min);\n  }\n\n  // 否则使用预定义的范围进行归一化\n  const configs: Record<string, NormalizationConfig> = {\n    // fuzzy_search 分数通常在 [0, 1] 区间\n    fuzzy: { minScore: 0, maxScore: 1 },\n    // 向量相似度已经在 [0, 1] 区间（余弦相似度）\n    vector: { minScore: 0, maxScore: 1 },\n    // BM25 分数范围不固定，但通常在 [0, +∞)，使用 Sigmoid 压缩\n    bm25: { minScore: 0, maxScore: 10 }\n  };\n\n  const config = configs[type] || { minScore: 0, maxScore: 1 };\n\n  if (type === 'bm25') {\n    // BM25 使用 Sigmoid 函数压缩到 [0, 1]\n    return 1 / (1 + Math.exp(-score / 2));\n  }\n\n  // 简单的线性归一化\n  const normalized = (score - config.minScore) / (config.maxScore - config.minScore);\n  return Math.max(0, Math.min(1, normalized));\n}\n\n/**\n * 计算混合分数（支持可配置权重）\n * @param normalizedScores 各类型归一化后的分数\n * @param weights 各类型的权重配置\n */\nfunction calculateHybridScore(\n  normalizedScores: {\n    fuzzy?: number;\n    vector?: number;\n    bm25?: number;\n  },\n  weights: {\n    fuzzyWeight: number;\n    vectorWeight: number;\n    bm25Weight: number;\n  }\n): number {\n  let totalScore = 0;\n  let totalWeight = 0;\n\n  if (normalizedScores.fuzzy !== undefined && weights.fuzzyWeight > 0) {\n    totalScore += normalizedScores.fuzzy * weights.fuzzyWeight;\n    totalWeight += weights.fuzzyWeight;\n  }\n\n  if (normalizedScores.vector !== undefined && weights.vectorWeight > 0) {\n    totalScore += normalizedScores.vector * weights.vectorWeight;\n    totalWeight += weights.vectorWeight;\n  }\n\n  if (normalizedScores.bm25 !== undefined && weights.bm25Weight > 0) {\n    totalScore += normalizedScores.bm25 * weights.bm25Weight;\n    totalWeight += weights.bm25Weight;\n  }\n\n  // 如果没有任何有效分数，返回 0\n  if (totalWeight === 0) return 0;\n\n  return totalScore / totalWeight;\n}\n\n/**\n * 合并相同文档的不同检索结果\n * @param results 所有检索结果\n * @param weights 权重配置\n */\nfunction mergeResultsByDocument(\n  results: SearchResult[],\n  weights: {\n    fuzzyWeight: number;\n    vectorWeight: number;\n    bm25Weight: number;\n  }\n): SearchResult[] {\n  // 按文档分组\n  const docGroups = new Map<string, SearchResult[]>();\n\n  for (const result of results) {\n    const key = `${result.filename}-${generateContentHash(result.content)}`;\n    if (!docGroups.has(key)) {\n      docGroups.set(key, []);\n    }\n    docGroups.get(key)!.push(result);\n  }\n\n  // 对每个文档组，计算混合分数\n  const mergedResults: SearchResult[] = [];\n\n  for (const group of docGroups.values()) {\n    // 收集各类型的最高分数\n    const scoresByType: Record<string, number[]> = { fuzzy: [], vector: [], bm25: [] };\n\n    for (const result of group) {\n      if (!scoresByType[result.type]) {\n        scoresByType[result.type] = [];\n      }\n      scoresByType[result.type].push(result.rawScore);\n    }\n\n    // 计算归一化分数\n    let bestFuzzyScore = 0;\n    let bestVectorScore = 0;\n    let bestBm25Score = 0;\n\n    if (scoresByType.fuzzy.length > 0) {\n      const maxFuzzy = Math.max(...scoresByType.fuzzy);\n      bestFuzzyScore = normalizeScore(maxFuzzy, 'fuzzy');\n    }\n\n    if (scoresByType.vector.length > 0) {\n      const maxVector = Math.max(...scoresByType.vector);\n      bestVectorScore = normalizeScore(maxVector, 'vector');\n    }\n\n    if (scoresByType.bm25.length > 0) {\n      const maxBm25 = Math.max(...scoresByType.bm25);\n      bestBm25Score = normalizeScore(maxBm25, 'bm25');\n    }\n\n    // 计算混合分数\n    const hybridScore = calculateHybridScore(\n      {\n        fuzzy: bestFuzzyScore || undefined,\n        vector: bestVectorScore || undefined,\n        bm25: bestBm25Score || undefined\n      },\n      weights\n    );\n\n    // 选择分数最高的结果作为基础\n    const bestResult = group.reduce((best, current) =>\n      current.rawScore > best.rawScore ? current : best\n    );\n\n    mergedResults.push({\n      ...bestResult,\n      rawScore: hybridScore,\n      normalizedScore: hybridScore,\n      type: bestResult.type // 保留主要检索类型\n    });\n  }\n\n  return mergedResults;\n}\n\n/**\n * 计算两个文本的重叠度（基于字符级的最长公共子序列简化版本）\n */\nfunction calculateContentOverlap(content1: string, content2: string): number {\n  const normalized1 = content1.trim().toLowerCase();\n  const normalized2 = content2.trim().toLowerCase();\n\n  // 如果任一内容为空，返回 0\n  if (!normalized1 || !normalized2) return 0;\n\n  // 简化的重叠度计算：计算共同字符的比例\n  const set1 = new Set(normalized1.split(''));\n  const set2 = new Set(normalized2.split(''));\n\n  const intersection = new Set([...set1].filter(char => set2.has(char)));\n  const union = new Set([...set1, ...set2]);\n\n  if (union.size === 0) return 0;\n\n  // Jaccard 相似度\n  return intersection.size / union.size;\n}\n\n/**\n * 当文件被更新时处理，更新向量数据库\n */\nexport async function handleFileUpdate(filePath: string, content: string): Promise<void> {\n  if (!filePath.endsWith('.md')) return;\n\n  try {\n    await processMarkdownFile(filePath, content);\n  } catch (error) {\n    handleRAGError(error, `更新文件 ${filePath} 的向量失败`, false);\n  }\n}\n\n/**\n * 检查是否有嵌入模型可用\n */\nexport async function checkEmbeddingModelAvailable(): Promise<boolean> {\n  try {\n    // 尝试计算一个简单文本的向量\n    const embedding = await fetchEmbedding('测试嵌入模型');\n    return !!embedding;\n  } catch (error) {\n    handleRAGError(error, '嵌入模型检查失败', false);\n    return false;\n  }\n}\n\n/**\n * 显示向量处理进度的toast\n */\nexport function showVectorProcessingToast(message: string) {\n  toast({\n    title: '向量数据库更新',\n    description: message,\n  });\n}\n\n/**\n * 从指定文件夹中收集Markdown文件内容\n */\nasync function collectMarkdownContentsInFolder(folderPath: string): Promise<SearchItem[]> {\n  try {\n    const workspace = await getWorkspacePath();\n    const items: SearchItem[] = [];\n\n    // 构建文件夹完整路径\n    let fullFolderPath: string;\n    if (workspace.isCustom) {\n      fullFolderPath = await join(workspace.path, folderPath);\n    } else {\n      fullFolderPath = folderPath;\n    }\n\n    // 递归读取文件夹内容\n    async function processTree(dirPath: string, relativePath: string): Promise<void> {\n      let currentEntries: DirEntry[];\n\n      if (workspace.isCustom) {\n        currentEntries = await readDir(dirPath);\n      } else {\n        const { path, baseDir } = await getFilePathOptions(relativePath);\n        currentEntries = await readDir(path, { baseDir });\n      }\n\n      for (const entry of currentEntries) {\n        if (entry.name.startsWith('.')) continue;\n\n        const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;\n\n        if (entry.isDirectory) {\n          const entryFullPath = workspace.isCustom\n            ? await join(dirPath, entry.name)\n            : entryRelativePath;\n          await processTree(entryFullPath, entryRelativePath);\n        } else if (entry.name.endsWith('.md')) {\n          // 读取文件内容并添加到 items\n          try {\n            let content = '';\n            const entryFullPath = workspace.isCustom\n              ? await join(dirPath, entry.name)\n              : entryRelativePath;\n\n            if (workspace.isCustom) {\n              content = await readTextFile(entryFullPath);\n            } else {\n              const { path, baseDir } = await getFilePathOptions(entryRelativePath);\n              content = await readTextFile(path, { baseDir });\n            }\n\n            items.push({\n              id: entryRelativePath,\n              title: entry.name,\n              article: content,\n              search_type: 'markdown'\n            });\n          } catch (error) {\n            console.error(`读取文件 ${entryRelativePath} 失败:`, error);\n          }\n        }\n      }\n    }\n\n    await processTree(fullFolderPath, folderPath);\n    return items;\n  } catch (error) {\n    console.error('收集文件夹Markdown内容失败:', error);\n    return [];\n  }\n}\n\n/**\n * 在指定文件夹范围内获取相关上下文\n * @param keywords 关键词数组\n * @param folderPath 文件夹相对路径\n * @returns 包含上下文文本和引用文件名的对象\n */\nexport async function getContextForQueryInFolder(\n  keywords: Keyword[],\n  folderPath: string\n): Promise<{ context: string; sources: string[]; sourceDetails: RagSource[] }> {\n  try {\n    const store = await Store.load('store.json');\n    const resultCount = await store.get<number>('ragResultCount') || 5;\n    const similarityThreshold = await store.get<number>('ragSimilarityThreshold') || 0.25;\n\n    // 读取权重配置\n    const fuzzyWeight = await store.get<number>('ragFuzzyWeight') ?? 0.2;\n    const vectorWeight = await store.get<number>('ragVectorWeight') ?? 0.7;\n    const bm25Weight = await store.get<number>('ragBm25Weight') ?? 0.1;\n\n    const weights = {\n      fuzzyWeight,\n      vectorWeight,\n      bm25Weight\n    };\n\n    const allResults: SearchResult[] = [];\n\n    if (!keywords || keywords.length === 0) {\n      return { context: '', sources: [], sourceDetails: [] };\n    }\n\n    // 读取查询扩展配置\n    const enableQueryExpansion = await store.get<boolean>('ragEnableQueryExpansion') ?? true;\n    const maxQueryVariations = await store.get<number>('ragMaxQueryVariations') ?? 3;\n\n    // 应用查询转换（生成同义词变体）\n    const expandedKeywords = transformQueries(keywords, enableQueryExpansion, maxQueryVariations);\n\n    const sortedKeywords = [...expandedKeywords].sort((a, b) => b.weight - a.weight);\n\n    // 收集文件夹范围内的文件\n    const items = await collectMarkdownContentsInFolder(folderPath);\n    const folderFilenames = new Set(items.map(item => item.title || ''));\n\n    // 1. 模糊搜索（限定到文件夹）\n    try {\n      if (items.length > 0) {\n        for (const keyword of sortedKeywords) {\n          // 跳过停用词的模糊搜索\n          if (isStopWord(keyword.text)) {\n            continue;\n          }\n\n          const fuzzyResults: FuzzySearchResult[] = await invoke('fuzzy_search', {\n            items,\n            query: keyword.text,\n            keys: ['title', 'article'],\n            threshold: 0.3,\n            includeScore: true,\n            includeMatches: true\n          });\n\n          for (const result of fuzzyResults) {\n            if (result.score > 0) {\n              const item = result.item;\n              const articleMatches = result.matches.filter(m => m.key === 'article');\n              if (articleMatches.length > 0) {\n                const match = articleMatches[0];\n                const content = match.value;\n\n                let startIdx = 0;\n                let endIdx = content.length;\n                if (match.indices.length > 0) {\n                  const firstMatch = match.indices[0];\n                  startIdx = Math.max(0, firstMatch[0] - 250);\n                  endIdx = Math.min(content.length, firstMatch[1] + 250);\n                }\n\n                const contextSnippet = content.substring(startIdx, endIdx);\n\n                allResults.push({\n                  filename: item.title || '未命名文件',\n                  filepath: item.id || '',\n                  content: contextSnippet,\n                  rawScore: result.score * keyword.weight,\n                  normalizedScore: 0,\n                  keyword: keyword.text,\n                  type: 'fuzzy'\n                });\n              }\n            }\n          }\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, '模糊搜索失败', false);\n    }\n\n    // 2. 向量搜索 - 过滤到文件夹范围\n    try {\n      const windowSize = await store.get<number>('ragWindowSize') ?? 2;\n\n      for (const keyword of sortedKeywords) {\n        const queryEmbedding = await fetchEmbedding(keyword.text);\n        if (queryEmbedding) {\n          let similarDocs = await getSimilarDocuments(queryEmbedding, resultCount * 2, similarityThreshold);\n          // 过滤：只保留文件夹内的文件\n          similarDocs = similarDocs.filter(doc => folderFilenames.has(doc.filename));\n\n          if (similarDocs.length > 0) {\n            similarDocs = await rerankDocuments(keyword.text, similarDocs);\n\n            // 应用句子窗口扩展\n            const expandedDocs = await expandWithSentenceWindow(similarDocs, windowSize);\n\n            for (const doc of expandedDocs) {\n              allResults.push({\n                filename: doc.filename,\n                filepath: doc.filename,\n                content: doc.content,\n                rawScore: (doc.similarity || 0) * keyword.weight,\n                normalizedScore: 0,\n                keyword: keyword.text,\n                type: 'vector'\n              });\n            }\n          }\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, '向量搜索失败', false);\n    }\n\n    // 3. 使用 BM25 搜索找到相关文档（限定到文件夹范围）\n    try {\n      const itemsMap = new Map(items.map(item => [item.id || item.title || '', item]));\n\n      for (const keyword of sortedKeywords) {\n        const bm25Results = await searchWithBM25(keyword.text, resultCount);\n\n        for (const result of bm25Results) {\n          const item = itemsMap.get(result.id);\n          if (!item || !item.article) continue;\n\n          // 验证文件在文件夹范围内\n          if (!folderFilenames.has(item.title || '')) continue;\n\n          const articleLower = item.article.toLowerCase();\n          const keywordLower = keyword.text.toLowerCase();\n          const keywordIndex = articleLower.indexOf(keywordLower);\n\n          let startIdx = 0;\n          let endIdx = item.article.length;\n\n          if (keywordIndex >= 0) {\n            startIdx = Math.max(0, keywordIndex - 250);\n            endIdx = Math.min(item.article.length, keywordIndex + keyword.text.length + 250);\n          } else {\n            const mid = Math.floor(item.article.length / 2);\n            startIdx = Math.max(0, mid - 250);\n            endIdx = Math.min(item.article.length, mid + 250);\n          }\n\n          const contextSnippet = item.article.substring(startIdx, endIdx);\n\n          allResults.push({\n            filename: item.title || '未命名文件',\n            filepath: item.id || '',\n            content: contextSnippet,\n            rawScore: result.score * keyword.weight,\n            normalizedScore: 0,\n            keyword: keyword.text,\n            type: 'bm25'\n          });\n        }\n      }\n    } catch (error) {\n      handleRAGError(error, 'BM25 搜索失败', false);\n    }\n\n    // 如果没有找到任何相关上下文，返回空结果\n    if (allResults.length === 0) {\n      return { context: '', sources: [], sourceDetails: [] };\n    }\n\n    // 3. 按文档合并结果，使用归一化和混合权重\n    const mergedResults = mergeResultsByDocument(allResults, weights);\n\n    // 4. 对相似内容进行合并\n    const finalUniqueResults: SearchResult[] = [];\n    const mergedIndices = new Set<number>();\n\n    for (let i = 0; i < mergedResults.length; i++) {\n      if (mergedIndices.has(i)) continue;\n\n      const current = mergedResults[i];\n      let bestScore = current.normalizedScore;\n      let bestContent = current.content;\n      const mergedKeywords: string[] = [];\n\n      if (current.keyword) {\n        mergedKeywords.push(current.keyword);\n      }\n\n      for (let j = i + 1; j < mergedResults.length; j++) {\n        if (mergedIndices.has(j)) continue;\n\n        const other = mergedResults[j];\n        if (other.filename !== current.filename) continue;\n\n        const overlap = calculateContentOverlap(current.content, other.content);\n\n        if (overlap > 0.7) {\n          mergedIndices.add(j);\n          if (other.normalizedScore > bestScore) {\n            bestScore = other.normalizedScore;\n            bestContent = other.content;\n          }\n          if (other.keyword && !mergedKeywords.includes(other.keyword)) {\n            mergedKeywords.push(other.keyword);\n          }\n        }\n      }\n\n      finalUniqueResults.push({\n        ...current,\n        content: bestContent,\n        normalizedScore: bestScore,\n        keyword: mergedKeywords.join(', ')\n      });\n    }\n\n    finalUniqueResults.sort((a: SearchResult, b: SearchResult) => b.normalizedScore - a.normalizedScore);\n    const finalResults = finalUniqueResults.slice(0, resultCount);\n\n    const sources = Array.from(new Set(finalResults.map((ctx: SearchResult) => ctx.filename)));\n\n    // 构建 sourceDetails\n    const sourceDetailsMap = new Map<string, RagSource>();\n    for (const ctx of finalResults) {\n      if (!sourceDetailsMap.has(ctx.filename)) {\n        sourceDetailsMap.set(ctx.filename, {\n          filepath: ctx.filepath,\n          filename: ctx.filename,\n          content: ctx.content\n        })\n      }\n    }\n    const sourceDetails = Array.from(sourceDetailsMap.values())\n\n    const context = finalResults.map((ctx: SearchResult) => {\n      return `文件：${ctx.filename}\n${ctx.content}\n`;\n    }).join('\\n---\\n\\n');\n\n    return { context, sources, sourceDetails };\n  } catch (error) {\n    handleRAGError(error, '获取文件夹查询上下文失败', false);\n    return { context: '', sources: [], sourceDetails: [] };\n  }\n}\n"
  },
  {
    "path": "src/lib/record-navigation.ts",
    "content": "import { isMobileDevice } from './check'\nimport { useSidebarStore } from '@/stores/sidebar'\n\n/**\n * 记录完成后的导航处理\n * 桌面端：切换到记录 tab\n * 移动端：跳转到记录页面\n */\nexport function handleRecordComplete(router?: any) {\n  const isMobile = isMobileDevice()\n  \n  if (isMobile) {\n    // 移动端：跳转到记录页面\n    if (router) {\n      router.push('/mobile/record')\n    } else if (typeof window !== 'undefined') {\n      window.location.href = '/mobile/record'\n    }\n  } else {\n    // 桌面端：切换到记录 tab\n    const { setLeftSidebarTab } = useSidebarStore.getState()\n    setLeftSidebarTab('notes')\n  }\n}\n"
  },
  {
    "path": "src/lib/search-utils.ts",
    "content": "export interface SearchMatch {\n  text: string\n  index: number\n  length: number\n  isExact: boolean\n}\n\nexport interface SearchableItem {\n  id: string\n  title: string\n  content: string\n  metadata?: Record<string, any>\n}\n\nexport interface SearchResult<T = any> {\n  item: T\n  matches: SearchMatch[]\n  score: number\n  highlightText: string\n  matchType: 'exact' | 'fuzzy'\n}\n\n/**\n * 在文本中查找所有精确匹配项\n */\nfunction findExactMatches(text: string, query: string): SearchMatch[] {\n  const matches: SearchMatch[] = []\n  const searchText = text.toLowerCase()\n  const searchQuery = query.toLowerCase().trim()\n  \n  if (!searchQuery) return matches\n  \n  let index = 0\n  while (index < searchText.length) {\n    const foundIndex = searchText.indexOf(searchQuery, index)\n    if (foundIndex === -1) break\n    \n    matches.push({\n      text: text.substring(foundIndex, foundIndex + searchQuery.length),\n      index: foundIndex,\n      length: searchQuery.length,\n      isExact: true\n    })\n    \n    index = foundIndex + 1\n  }\n  \n  return matches\n}\n\n/**\n * 在文本中查找模糊匹配项（分词匹配）\n */\nfunction findFuzzyMatches(text: string, query: string): SearchMatch[] {\n  const matches: SearchMatch[] = []\n  const searchText = text.toLowerCase()\n  const searchQuery = query.toLowerCase().trim()\n  \n  if (!searchQuery || searchQuery.length < 2) return matches\n  \n  // 将查询词拆分成单个字符进行模糊匹配\n  const queryChars = searchQuery.split('')\n  \n  // 对于中文和英文都有效的模糊匹配\n  // 查找包含查询词中任意字符的词语\n  const words = text.split(/[\\s\\n,.，。、；;！!？?()（）\\[\\]【】]+/).filter(w => w.length > 0)\n  \n  for (const word of words) {\n    const wordLower = word.toLowerCase()\n    \n    // 检查是否包含查询词的部分字符\n    let matchCount = 0\n    for (const char of queryChars) {\n      if (wordLower.includes(char)) {\n        matchCount++\n      }\n    }\n    \n    // 如果匹配了查询词的大部分字符，认为是模糊匹配\n    const matchRatio = matchCount / queryChars.length\n    if (matchRatio >= 0.5 && word.length >= 2) {\n      const wordIndex = searchText.indexOf(wordLower)\n      if (wordIndex !== -1) {\n        matches.push({\n          text: text.substring(wordIndex, wordIndex + word.length),\n          index: wordIndex,\n          length: word.length,\n          isExact: false\n        })\n      }\n    }\n  }\n  \n  return matches\n}\n\n/**\n * 计算搜索结果的评分\n */\nfunction calculateScore(\n  contentMatches: SearchMatch[],\n  titleMatches: SearchMatch[],\n  matchType: 'exact' | 'fuzzy'\n): number {\n  let score = 0\n  \n  // 精确匹配基础分更高\n  const baseScore = matchType === 'exact' ? 100 : 50\n  \n  // 标题匹配权重 3x\n  score += titleMatches.length * baseScore * 3\n  \n  // 内容匹配权重 1x\n  score += contentMatches.length * baseScore\n  \n  // 匹配数量加成\n  score += (contentMatches.length + titleMatches.length) * 5\n  \n  return score\n}\n\n/**\n * 生成高亮文本片段\n */\nfunction generateHighlight(text: string, matches: SearchMatch[], maxLength: number = 200): string {\n  if (matches.length === 0) {\n    return text.substring(0, maxLength)\n  }\n  \n  // 使用第一个匹配位置作为中心\n  const firstMatch = matches[0]\n  const start = Math.max(0, firstMatch.index - 50)\n  const end = Math.min(text.length, firstMatch.index + maxLength)\n  \n  let snippet = text.substring(start, end)\n  \n  if (start > 0) snippet = '...' + snippet\n  if (end < text.length) snippet = snippet + '...'\n  \n  return snippet\n}\n\n/**\n * 执行搜索（自动合并精确和模糊搜索结果）\n */\nexport function search<T extends SearchableItem>(\n  items: T[],\n  query: string,\n  options: { maxResults?: number } = {}\n): SearchResult<T>[] {\n  if (!query.trim()) return []\n  \n  const { maxResults = 100 } = options\n  const exactResults: SearchResult<T>[] = []\n  const fuzzyResults: SearchResult<T>[] = []\n  \n  for (const item of items) {\n    // 精确搜索\n    const exactTitleMatches = findExactMatches(item.title, query)\n    const exactContentMatches = findExactMatches(item.content, query)\n    \n    if (exactTitleMatches.length > 0 || exactContentMatches.length > 0) {\n      const score = calculateScore(exactContentMatches, exactTitleMatches, 'exact')\n      const highlightText = generateHighlight(item.content, exactContentMatches)\n      \n      exactResults.push({\n        item,\n        matches: [...exactTitleMatches, ...exactContentMatches],\n        score,\n        highlightText,\n        matchType: 'exact'\n      })\n    } else {\n      // 只有在没有精确匹配时才进行模糊搜索\n      const fuzzyTitleMatches = findFuzzyMatches(item.title, query)\n      const fuzzyContentMatches = findFuzzyMatches(item.content, query)\n      \n      if (fuzzyTitleMatches.length > 0 || fuzzyContentMatches.length > 0) {\n        const score = calculateScore(fuzzyContentMatches, fuzzyTitleMatches, 'fuzzy')\n        const highlightText = generateHighlight(item.content, fuzzyContentMatches)\n        \n        fuzzyResults.push({\n          item,\n          matches: [...fuzzyTitleMatches, ...fuzzyContentMatches],\n          score,\n          highlightText,\n          matchType: 'fuzzy'\n        })\n      }\n    }\n  }\n  \n  // 精确匹配按评分排序\n  exactResults.sort((a, b) => b.score - a.score)\n  \n  // 模糊匹配按评分排序\n  fuzzyResults.sort((a, b) => b.score - a.score)\n  \n  // 合并结果：精确匹配在前，模糊匹配在后\n  const allResults = [...exactResults, ...fuzzyResults]\n  \n  // 限制结果数量\n  return allResults.slice(0, maxResults)\n}\n"
  },
  {
    "path": "src/lib/shortcut/quick-record-text.ts",
    "content": "import emitter from '@/lib/emitter';\nimport {getCurrentWebviewWindow} from '@tauri-apps/api/webviewWindow';\n\nexport default function initQuickRecordText() {\n    emitter.on('quickRecordText', async () => {\n        const window = getCurrentWebviewWindow()\n        if(!window) return\n        if (!(await window.isVisible())) {\n            await window.show()\n            await window.setFocus()\n            await window.setAlwaysOnTop(true)\n            await window.setAlwaysOnTop(false)\n            setTimeout(() => {\n                emitter.emit('quickRecordTextHandler')\n            }, 300);\n        } else if (await window.isMinimized()) {\n            await window.unminimize()\n            setTimeout(async () => {\n                await window.show()\n                await window.setFocus()\n                await window.setAlwaysOnTop(true)\n                await window.setAlwaysOnTop(false)\n                emitter.emit('quickRecordTextHandler')\n            }, 100);\n        } else {\n            // 增加判断窗口是否在最前面\n            const isFocused = await window.isFocused();\n            if (!isFocused) {\n                await window.setFocus();\n                await window.setAlwaysOnTop(true);\n                await window.setAlwaysOnTop(false);\n            }\n            emitter.emit('quickRecordTextHandler')\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/shortcut/show-window.ts",
    "content": "import emitter from '@/lib/emitter';\nimport {getCurrentWebviewWindow} from '@tauri-apps/api/webviewWindow';\n\nexport default function initShowWindow() {\n    emitter.on('openWindow', async () => {\n        const window = getCurrentWebviewWindow()\n        if (!window) return\n        if (!(await window.isVisible())) {\n            await window.show()\n            await window.setFocus()\n            await window.setAlwaysOnTop(true)\n            await window.setAlwaysOnTop(false)\n        } else if (await window.isMinimized()) {\n            await window.unminimize()\n            setTimeout(async () => {\n                await window.show()\n                await window.setFocus()\n                await window.setAlwaysOnTop(true)\n                await window.setAlwaysOnTop(false)\n            }, 100)\n        } else {\n            // 增加判断窗口是否在最前面\n            const isFocused = await window.isFocused();\n            if (!isFocused) {\n                await window.setFocus();\n                await window.setAlwaysOnTop(true);\n                await window.setAlwaysOnTop(false);\n            } else {\n                await window.hide()\n            }\n        }\n    })\n}\n"
  },
  {
    "path": "src/lib/skills/dependency-installer.ts",
    "content": "/**\n * Dependency Installer for Skills\n *\n * Automatically detects missing dependencies from error messages and installs them.\n */\n\nimport { Command } from '@tauri-apps/plugin-shell'\nimport { writeTextFile, exists } from '@tauri-apps/plugin-fs'\n\n/**\n * Parsed dependency information\n */\nexport interface DependencyInfo {\n  type: 'python' | 'node' | 'unknown'\n  moduleName: string\n  installCommand: string\n  installArgs: string[]\n}\n\n/**\n * Dependency installation result\n */\nexport interface InstallResult {\n  success: boolean\n  message: string\n  installed?: string\n  alreadyInstalled?: boolean\n}\n\nexport interface DependencyInstallRequest {\n  stderr: string\n  command: string\n  workingDirectory: string\n}\n\n/**\n * Module to package name mapping\n * Handles cases where module name differs from package name\n */\nconst MODULE_TO_PACKAGE: Record<string, { python?: string; node?: string }> = {\n  // Python modules\n  'pptx': { python: 'python-pptx' },\n  'PIL': { python: 'Pillow' },\n  'PIL.Image': { python: 'Pillow' },\n  'markitdown': { python: 'markitdown[pptx]' },\n  'openai': { python: 'openai', node: 'openai' },\n  'anthropic': { python: 'anthropic' },\n  'numpy': { python: 'numpy' },\n  'pandas': { python: 'pandas' },\n  'matplotlib': { python: 'matplotlib' },\n  'requests': { python: 'requests' },\n\n  // Node modules\n  'pptxgenjs': { node: 'pptxgenjs' },\n  '@anthropic-ai/sdk': { node: '@anthropic-ai/sdk' },\n}\n\n/**\n * Parse error message to extract missing dependency\n */\nexport function parseDependencyError(stderr: string): DependencyInfo | null {\n  if (!stderr) return null\n\n  const lines = stderr.split('\\n')\n  const errorLine = lines.find(l =>\n    l.includes('ModuleNotFoundError') ||\n    l.includes('No module named') ||\n    l.includes('Cannot find module') ||\n    l.includes(\"Cannot find package\")\n  )\n\n  if (!errorLine) return null\n\n  // Python: ModuleNotFoundError: No module named 'pptx'\n  const pythonMatch = errorLine.match(/No module named ['\"]([^'\"]+)['\"]/) ||\n                     errorLine.match(/ModuleNotFoundError.*['\"]([^'\"]+)['\"]/)\n\n  if (pythonMatch) {\n    const moduleName = pythonMatch[1]\n    const packageName = MODULE_TO_PACKAGE[moduleName]?.python || moduleName\n\n    return {\n      type: 'python',\n      moduleName,\n      installCommand: 'pip',\n      installArgs: ['install', packageName],\n    }\n  }\n\n  // Node: Error: Cannot find module 'pptxgenjs'\n  const nodeMatch = errorLine.match(/Cannot find module ['\"]([^'\"]+)['\"]/) ||\n                   errorLine.match(/Cannot find package ['\"]([^'\"]+)['\"]/)\n\n  if (nodeMatch) {\n    const moduleName = nodeMatch[1]\n\n    // 如果匹配到的是路径而非模块名（如包含 / 或 .js 后缀），跳过\n    if (moduleName.includes('/') || moduleName.includes('\\\\') || moduleName.endsWith('.js')) {\n      return null\n    }\n\n    // 过滤有效的模块名（只能包含字母、数字、@、-、_）\n    if (!/^[a-zA-Z0-9@_-]+$/.test(moduleName)) {\n      return null\n    }\n\n    const packageName = MODULE_TO_PACKAGE[moduleName]?.node || moduleName\n\n    return {\n      type: 'node',\n      moduleName,\n      installCommand: 'npm',\n      installArgs: ['install', '-g', packageName],\n    }\n  }\n\n  return null\n}\n\n/**\n * Check if a command exists (for fallback to pip3, npm, etc.)\n */\nexport async function commandExists(cmd: string): Promise<boolean> {\n  try {\n    const result = await Command.create('bash', ['-c', `command -v \"${cmd}\"`]).execute()\n    return result.code === 0\n  } catch {\n    return false\n  }\n}\n\nasync function detectNodePackageManager(workingDirectory: string): Promise<'pnpm' | 'npm' | 'yarn' | null> {\n  const candidates: Array<{ lockFile: string; command: 'pnpm' | 'npm' | 'yarn' }> = [\n    { lockFile: 'pnpm-lock.yaml', command: 'pnpm' },\n    { lockFile: 'package-lock.json', command: 'npm' },\n    { lockFile: 'yarn.lock', command: 'yarn' },\n  ]\n\n  for (const candidate of candidates) {\n    if (await exists(`${workingDirectory}/${candidate.lockFile}`) && await commandExists(candidate.command)) {\n      return candidate.command\n    }\n  }\n\n  for (const command of ['pnpm', 'npm', 'yarn'] as const) {\n    if (await commandExists(command)) {\n      return command\n    }\n  }\n\n  return null\n}\n\nexport async function detectPythonCommand(preferred: string): Promise<string | null> {\n  if (await commandExists(preferred)) {\n    return preferred\n  }\n\n  for (const command of ['python3', 'python'] as const) {\n    if (await commandExists(command)) {\n      return command\n    }\n  }\n\n  return null\n}\n\n/**\n * Install a dependency\n * @param dep - Dependency info\n * @param targetDir - Optional target directory for installation (e.g., appDataPath for node_modules)\n */\nexport async function installDependency(dep: DependencyInfo, targetDir?: string): Promise<InstallResult> {\n  const { installCommand, installArgs, moduleName, type } = dep\n\n  try {\n    // For Node.js modules, if targetDir is provided, install locally there\n    if (type === 'node' && targetDir) {\n      // Check if package.json exists, if not create one\n      const packageJsonPath = `${targetDir}/package.json`\n      const hasPackageJson = await exists(packageJsonPath)\n\n      if (!hasPackageJson) {\n        // Create a minimal package.json\n        await writeTextFile(packageJsonPath, JSON.stringify({\n          name: 'note-gen-skills',\n          version: '1.0.0',\n          description: 'Dependencies for NoteGen skills',\n          private: true\n        }, null, 2))\n      }\n\n      // Install in target directory\n      const installCmd = `cd \"${targetDir}\" && npm install ${moduleName}`\n\n      const result = await Command.create('bash', ['-c', installCmd]).execute()\n\n      if (result.code === 0) {\n        return {\n          success: true,\n          message: `Successfully installed node module '${moduleName}' in ${targetDir}`,\n          installed: moduleName,\n        }\n      } else {\n        return {\n          success: false,\n          message: `Failed to install node module '${moduleName}': ${result.stderr || 'Unknown error'}`,\n        }\n      }\n    }\n\n    // Try with fallback commands (e.g., pip -> pip3, python -> python3)\n    const fallbacks = {\n      pip: ['pip3'],\n      python: ['python3'],\n      npm: [], // npm usually doesn't have a fallback\n    }\n\n    const possibleCommands = [installCommand, ...(fallbacks[installCommand as keyof typeof fallbacks] || [])]\n\n    for (const cmd of possibleCommands) {\n      // Check if command exists\n      if (!(await commandExists(cmd))) {\n        continue\n      }\n\n      const args = installArgs.map(a => a.replace(installCommand, cmd))\n      const shellCommand = `${cmd} ${args.join(' ')}`\n\n      const result = await Command.create('bash', ['-c', shellCommand]).execute()\n\n      if (result.code === 0) {\n        return {\n          success: true,\n          message: `Successfully installed ${type} module '${moduleName}' using ${shellCommand}`,\n          installed: moduleName,\n        }\n      }\n    }\n\n    return {\n      success: false,\n      message: `Failed to install ${type} module '${moduleName}'. Tried commands: ${possibleCommands.join(', ')}. Please install manually.`,\n    }\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error)\n\n    return {\n      success: false,\n      message: `Error installing ${type} module '${moduleName}': ${errorMessage}`,\n    }\n  }\n}\n\n/**\n * Parse error and install dependency if applicable\n * Returns null if error is not a dependency error\n * @param stderr - Error message from command execution\n * @param targetDir - Optional target directory for dependency installation\n */\nexport async function handleDependencyError(stderr: string, targetDir?: string): Promise<InstallResult | null> {\n  const dep = parseDependencyError(stderr)\n\n  if (!dep) {\n    return null\n  }\n\n  return await installDependency(dep, targetDir)\n}\n\nexport async function ensureDependencyForCommand(\n  request: DependencyInstallRequest\n): Promise<InstallResult | null> {\n  const dep = parseDependencyError(request.stderr)\n\n  if (!dep) {\n    return null\n  }\n\n  if (dep.type === 'python') {\n    const pythonCommand = await detectPythonCommand(request.command.startsWith('python') ? request.command : 'python3')\n    if (!pythonCommand) {\n      return {\n        success: false,\n        message: `Python interpreter not found. Missing module: ${dep.moduleName}`,\n      }\n    }\n\n    const packageName = dep.installArgs[1]\n    const shellCommand = `cd \"${request.workingDirectory}\" && ${pythonCommand} -m pip install ${packageName}`\n    const result = await Command.create('bash', ['-c', shellCommand]).execute()\n\n    return result.code === 0\n      ? {\n          success: true,\n          message: `Successfully installed python module '${packageName}'`,\n          installed: packageName,\n        }\n      : {\n          success: false,\n          message: result.stderr || `Failed to install python module '${packageName}'`,\n        }\n  }\n\n  if (dep.type === 'node') {\n    const packageManager = await detectNodePackageManager(request.workingDirectory)\n    if (!packageManager) {\n      return {\n        success: false,\n        message: `No available Node package manager found. Missing module: ${dep.moduleName}`,\n      }\n    }\n\n    const packageName = dep.installArgs[dep.installArgs.length - 1]\n    const installCommands: Record<'pnpm' | 'npm' | 'yarn', string> = {\n      pnpm: `cd \"${request.workingDirectory}\" && pnpm add ${packageName}`,\n      npm: `cd \"${request.workingDirectory}\" && npm install ${packageName}`,\n      yarn: `cd \"${request.workingDirectory}\" && yarn add ${packageName}`,\n    }\n\n    const result = await Command.create('bash', ['-c', installCommands[packageManager]]).execute()\n\n    return result.code === 0\n      ? {\n          success: true,\n          message: `Successfully installed node module '${packageName}' using ${packageManager}`,\n          installed: packageName,\n        }\n      : {\n          success: false,\n          message: result.stderr || `Failed to install node module '${packageName}' using ${packageManager}`,\n        }\n  }\n\n  return null\n}\n"
  },
  {
    "path": "src/lib/skills/executor.ts",
    "content": "/**\n * Skill 执行器\n *\n * 负责 Skill 的执行和指令格式化。\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\nimport type {\n  SkillContent,\n  SkillExecutionResult,\n  SkillExecutionRecord,\n  ScriptExecutionResult,\n  SkillScript,\n} from './types'\nimport { resolveSkillDirectory, resolveScriptRelativePath, buildShellCommand } from './path-utils'\n\n// ============================================================================\n// SkillExecutor 类\n// ============================================================================\n\n/**\n * Skill 执行器类\n *\n * 负责：\n * - 在当前上下文执行 Skill\n * - 格式化 Skill 指令为系统提示\n * - 管理 Skill 执行记录\n * - 执行脚本 (scripts/)\n */\nexport class SkillExecutor {\n  private executionHistory: SkillExecutionRecord[] = []\n  private maxHistorySize = 100\n\n  // ========================================================================\n  // 执行方法\n  // ========================================================================\n\n  /**\n   * 执行单个 Skill\n   *\n   * 注意：此方法只格式化和返回指令，实际的 AI 执行由调用方完成\n   *\n   * @param skill - 要执行的 Skill\n   * @param userInput - 用户输入\n   * @returns 格式化后的指令内容\n   */\n  formatSkillForExecution(skill: SkillContent, userInput: string): string {\n    const sections: string[] = []\n\n    // 添加 Skill 标题\n    sections.push(`## Using Skill: ${skill.metadata.name}`)\n    sections.push('')\n\n    // 添加 Skill 描述\n    if (skill.metadata.description) {\n      sections.push(`**Description**: ${skill.metadata.description}`)\n      sections.push('')\n    }\n\n    // 添加兼容性信息 (官方规范)\n    if (skill.metadata.compatibility) {\n      sections.push(`**Compatibility**: ${skill.metadata.compatibility}`)\n      sections.push('')\n    }\n\n    // 添加许可证信息 (官方规范)\n    if (skill.metadata.license) {\n      sections.push(`**License**: ${skill.metadata.license}`)\n      sections.push('')\n    }\n\n    // 添加 Skill 版本信息\n    if (skill.metadata.version) {\n      sections.push(`**Version**: ${skill.metadata.version}`)\n    }\n    if (skill.metadata.author) {\n      sections.push(`**Author**: ${skill.metadata.author}`)\n    }\n    sections.push('')\n\n    // 添加可用脚本列表 (官方规范)\n    if (skill.scripts && skill.scripts.length > 0) {\n      sections.push('**Available Scripts**:')\n      for (const script of skill.scripts) {\n        sections.push(`  - \\`${script.name}\\` (${script.type})`)\n      }\n      sections.push('')\n    }\n\n    // 添加可用参考文档 (官方规范)\n    if (skill.references && skill.references.length > 0) {\n      sections.push('**Available References**:')\n      for (const ref of skill.references) {\n        sections.push(`  - [${ref.name}](${ref.path})`)\n      }\n      sections.push('')\n    }\n\n    // 添加分隔线\n    sections.push('---')\n    sections.push('')\n\n    // 添加指令内容\n    sections.push('### Instructions')\n    sections.push('')\n    sections.push(skill.instructions)\n    sections.push('')\n\n    // 添加用户输入上下文\n    sections.push('### User Request')\n    sections.push('')\n    sections.push(`> ${userInput}`)\n    sections.push('')\n\n    return sections.join('\\n')\n  }\n\n  /**\n   * 格式化多个 Skills 为系统提示\n   *\n   * @param skills - Skills 列表\n   * @returns 格式化后的系统提示\n   */\n  formatSkillsAsSystemPrompt(skills: SkillContent[]): string {\n    if (skills.length === 0) {\n      return ''\n    }\n\n    const sections: string[] = []\n\n    sections.push('# Available Skills')\n    sections.push('')\n    sections.push(\n      `You have access to ${skills.length} specialized skill(s). ` +\n      'When the user request matches a skill description, use that skill instructions to guide your response.'\n    )\n    sections.push('')\n\n    for (const skill of skills) {\n      sections.push(`## Skill: ${skill.metadata.name}`)\n      sections.push('')\n\n      if (skill.metadata.description) {\n        sections.push(`**Description**: ${skill.metadata.description}`)\n        sections.push('')\n      }\n\n      if (skill.metadata.compatibility) {\n        sections.push(`**Compatibility**: ${skill.metadata.compatibility}`)\n        sections.push('')\n      }\n\n      sections.push(skill.instructions)\n      sections.push('')\n\n      // 添加可用脚本\n      if (skill.scripts && skill.scripts.length > 0) {\n        sections.push('**Available Scripts**:')\n        for (const script of skill.scripts) {\n          sections.push(`  - \\`${script.name}\\` (${script.type})`)\n        }\n        sections.push('')\n      }\n\n      // 添加工具权限提示\n      if (skill.metadata.allowedTools && skill.metadata.allowedTools.length > 0) {\n        sections.push(\n          `**Pre-approved tools**: ${skill.metadata.allowedTools.join(', ')}`\n        )\n        sections.push('')\n      }\n\n      sections.push('---')\n      sections.push('')\n    }\n\n    return sections.join('\\n')\n  }\n\n  /**\n   * 格式化单个 Skill 为系统提示\n   *\n   * @param skill - Skill 内容\n   * @returns 格式化后的系统提示\n   */\n  formatSkillAsSystemPrompt(skill: SkillContent): string {\n    return this.formatSkillsAsSystemPrompt([skill])\n  }\n\n  // ========================================================================\n  // 脚本执行\n  // ========================================================================\n\n  /**\n   * 执行 Skill 的脚本\n   *\n   * @param skill - Skill 内容\n   * @param scriptName - 脚本名称\n   * @param args - 脚本参数 (可选)\n   * @returns 脚本执行结果\n   */\n  async executeScript(\n    skill: SkillContent,\n    scriptName: string,\n    args?: string[]\n  ): Promise<ScriptExecutionResult> {\n    const startTime = Date.now()\n\n    // 查找脚本\n    const script = skill.scripts.find(s => s.name === scriptName)\n    if (!script) {\n      return {\n        success: false,\n        scriptName,\n        error: `Script \"${scriptName}\" not found in skill \"${skill.metadata.name}\"`,\n        executionTime: Date.now() - startTime,\n      }\n    }\n\n    try {\n      // 根据脚本类型执行\n      const result = await this.executeScriptByType(script, args, skill)\n\n      const executionTime = Date.now() - startTime\n\n      return {\n        success: true,\n        scriptName,\n        output: result.output,\n        exitCode: result.exitCode,\n        executionTime,\n      }\n    } catch (error) {\n      const executionTime = Date.now() - startTime\n      const errorMessage = error instanceof Error ? error.message : String(error)\n\n      return {\n        success: false,\n        scriptName,\n        error: errorMessage,\n        executionTime,\n      }\n    }\n  }\n\n  /**\n   * 根据脚本类型执行脚本\n   */\n  private async executeScriptByType(\n    script: SkillScript,\n    args?: string[],\n    skill?: SkillContent\n  ): Promise<{ output: string; exitCode: number }> {\n    // 注意：在 Tauri 环境中，脚本执行需要通过 Command API\n\n    const { Command } = await import('@tauri-apps/plugin-shell')\n\n    let command: string\n\n    // 根据脚本类型确定解释器命令\n    // 优先使用 'python3'，如果不存在则回退到 'python'\n    switch (script.type) {\n      case 'python':\n        command = 'python3'\n        break\n      case 'bash':\n      case 'shell':\n        command = 'bash'\n        break\n      case 'node':\n      case 'javascript':\n        command = 'node'\n        break\n      default:\n        throw new Error(`Unsupported script type: ${script.type}`)\n    }\n\n    // 如果没有 skill 信息，抛出错误而不是静默失败\n    if (!skill) {\n      throw new Error('Skill information is required to execute script')\n    }\n\n    // 获取 skill 文件信息\n    const fileInfo = await import('@/lib/skills').then(m => m.skillManager.getSkillFileInfo(skill.metadata.id))\n    if (!fileInfo) {\n      throw new Error(`Cannot find skill file info for: ${skill.metadata.id}`)\n    }\n\n    // 使用统一的路径解析函数\n    const workingDirectory = await resolveSkillDirectory(fileInfo.directory, skill.metadata.scope)\n\n    // 使用统一的脚本路径解析函数\n    const skillBaseName = fileInfo.directory.split('/').pop() || skill.metadata.id\n    const relativeScriptPath = resolveScriptRelativePath(script.path, skillBaseName)\n\n    // 使用参数转义函数\n    const shellCommand = buildShellCommand(workingDirectory, workingDirectory, command, [relativeScriptPath, ...(args || [])])\n\n    const result = await Command.create('bash', ['-c', shellCommand]).execute()\n\n    // 合并 stdout 和 stderr，确保不丢失任何输出\n    const output = result.stdout + result.stderr\n\n    return {\n      output: output || '',\n      exitCode: result.code ?? 0,\n    }\n  }\n\n  /**\n   * 获取 Skill 的所有脚本\n   *\n   * @param skill - Skill 内容\n   * @returns 脚本列表\n   */\n  getScripts(skill: SkillContent): SkillScript[] {\n    return skill.scripts || []\n  }\n\n  /**\n   * 检查 Skill 是否有指定脚本\n   *\n   * @param skill - Skill 内容\n   * @param scriptName - 脚本名称\n   * @returns 是否存在\n   */\n  hasScript(skill: SkillContent, scriptName: string): boolean {\n    return skill.scripts.some(s => s.name === scriptName)\n  }\n\n  // ========================================================================\n  // 渐进式加载 (官方规范)\n  // ========================================================================\n\n  /**\n   * 获取 Skill 的元数据摘要 (用于启动时加载)\n   * 官方规范建议只加载约 100 tokens 的元数据\n   *\n   * @param skill - Skill 内容\n   * @returns 元数据摘要\n   */\n  getMetadataSummary(skill: SkillContent): string {\n    const parts: string[] = []\n    parts.push(`**${skill.metadata.name}**`)\n    parts.push(skill.metadata.description)\n\n    if (skill.metadata.compatibility) {\n      parts.push(`*Compatibility: ${skill.metadata.compatibility}*`)\n    }\n\n    return parts.join('\\n')\n  }\n\n  /**\n   * 读取参考文档内容 (按需加载)\n   *\n   * @param skill - Skill 内容\n   * @param referenceName - 参考文档名称\n   * @returns 参考文档内容\n   */\n  async loadReference(\n    skill: SkillContent,\n    referenceName: string\n  ): Promise<string | null> {\n    const reference = skill.references.find(r => r.name === referenceName)\n    if (!reference) {\n      return null\n    }\n\n    try {\n      const { readTextFile, BaseDirectory } = await import('@tauri-apps/plugin-fs')\n      const { getFilePathOptions } = await import('@/lib/workspace')\n\n      let content: string\n      if (skill.metadata.scope === 'global') {\n        content = await readTextFile(reference.path, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(reference.path)\n        if (options.baseDir) {\n          content = await readTextFile(options.path, { baseDir: options.baseDir })\n        } else {\n          content = await readTextFile(options.path)\n        }\n      }\n\n      return content\n    } catch (error) {\n      console.error(`读取参考文档失败: ${reference.path}`, error)\n      return null\n    }\n  }\n\n  /**\n   * 读取资源文件内容 (按需加载)\n   *\n   * @param skill - Skill 内容\n   * @param assetName - 资源文件名称\n   * @returns 资源文件内容\n   */\n  async loadAsset(\n    skill: SkillContent,\n    assetName: string\n  ): Promise<string | null> {\n    const asset = skill.assets.find(a => a.name === assetName)\n    if (!asset) {\n      return null\n    }\n\n    try {\n      const { readTextFile, BaseDirectory } = await import('@tauri-apps/plugin-fs')\n      const { getFilePathOptions } = await import('@/lib/workspace')\n\n      let content: string\n      if (skill.metadata.scope === 'global') {\n        content = await readTextFile(asset.path, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(asset.path)\n        if (options.baseDir) {\n          content = await readTextFile(options.path, { baseDir: options.baseDir })\n        } else {\n          content = await readTextFile(options.path)\n        }\n      }\n\n      return content\n    } catch (error) {\n      console.error(`读取资源文件失败: ${asset.path}`, error)\n      return null\n    }\n  }\n\n  // ========================================================================\n  // 执行记录管理\n  // ========================================================================\n\n  /**\n   * 创建执行记录\n   *\n   * @param skillId - Skill ID\n   * @param userInput - 用户输入\n   * @param result - 执行结果\n   * @returns 执行记录\n   */\n  createExecutionRecord(\n    skillId: string,\n    skillName: string,\n    userInput: string,\n    result: SkillExecutionResult\n  ): SkillExecutionRecord {\n    const record: SkillExecutionRecord = {\n      id: this.generateRecordId(),\n      skillId,\n      skillName,\n      userInput,\n      result,\n      timestamp: Date.now(),\n    }\n\n    // 添加到历史记录\n    this.executionHistory.unshift(record)\n\n    // 限制历史记录大小\n    if (this.executionHistory.length > this.maxHistorySize) {\n      this.executionHistory = this.executionHistory.slice(0, this.maxHistorySize)\n    }\n\n    return record\n  }\n\n  /**\n   * 获取执行历史\n   *\n   * @param limit - 限制返回数量\n   * @returns 执行记录列表\n   */\n  getExecutionHistory(limit?: number): SkillExecutionRecord[] {\n    if (limit) {\n      return this.executionHistory.slice(0, limit)\n    }\n    return [...this.executionHistory]\n  }\n\n  /**\n   * 获取指定 Skill 的执行历史\n   *\n   * @param skillId - Skill ID\n   * @param limit - 限制返回数量\n   * @returns 执行记录列表\n   */\n  getSkillExecutionHistory(skillId: string, limit?: number): SkillExecutionRecord[] {\n    const records = this.executionHistory.filter(r => r.skillId === skillId)\n    if (limit) {\n      return records.slice(0, limit)\n    }\n    return records\n  }\n\n  /**\n   * 清除执行历史\n   */\n  clearExecutionHistory(): void {\n    this.executionHistory = []\n  }\n\n  // ========================================================================\n  // 工具权限检查\n  // ========================================================================\n\n  /**\n   * 检查工具是否在 Skill 的允许列表中\n   *\n   * @param skill - Skill 内容\n   * @param toolName - 工具名称\n   * @returns 是否允许使用\n   */\n  isToolAllowed(skill: SkillContent, toolName: string): boolean {\n    if (!skill.metadata.allowedTools || skill.metadata.allowedTools.length === 0) {\n      return false\n    }\n    return skill.metadata.allowedTools.includes(toolName)\n  }\n\n  /**\n   * 获取 Skill 的所有允许工具\n   *\n   * @param skill - Skill 内容\n   * @returns 允许的工具列表\n   */\n  getAllowedTools(skill: SkillContent): string[] {\n    return skill.metadata.allowedTools || []\n  }\n\n  // ========================================================================\n  // 辅助方法\n  // ========================================================================\n\n  /**\n   * 生成记录 ID\n   */\n  private generateRecordId(): string {\n    return `record-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`\n  }\n\n  /**\n   * 创建执行结果\n   *\n   * @param success - 是否成功\n   * @param skillId - Skill ID\n   * @param result - 结果内容\n   * @param error - 错误信息\n   * @param toolsUsed - 使用的工具\n   * @param scriptsUsed - 使用的脚本\n   * @param startTime - 开始时间\n   * @returns 执行结果\n   */\n  createExecutionResult(\n    success: boolean,\n    skillId: string,\n    result?: string,\n    error?: string,\n    toolsUsed: string[] = [],\n    scriptsUsed: string[] = [],\n    startTime?: number\n  ): SkillExecutionResult {\n    const executionTime = startTime\n      ? Date.now() - startTime\n      : 0\n\n    return {\n      success,\n      skillId,\n      result,\n      error,\n      toolsUsed,\n      scriptsUsed,\n      executionTime,\n    }\n  }\n}\n\n// ============================================================================\n// 单例导出\n// ============================================================================\n\nexport const skillExecutor = new SkillExecutor()\n"
  },
  {
    "path": "src/lib/skills/index.ts",
    "content": "/**\n * Skills 模块导出入口\n *\n * 提供统一的 Skills 功能导出。\n */\n\n// 类型\nexport * from './types'\n\n// 解析器\nexport * from './parser'\n\n// 验证器\nexport * from './validator'\n\n// 管理器\nexport { skillManager, resetSkillManager } from './manager'\n\n// 执行器\nexport { skillExecutor, SkillExecutor } from './executor'\n\n// 工具函数\nexport * from './utils'\n"
  },
  {
    "path": "src/lib/skills/manager.ts",
    "content": "/**\n * Skill 管理器\n *\n * 负责 Skills 的发现、加载、注册和匹配。\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\nimport {\n  SkillContent,\n  SkillScope,\n  SkillFileInfo,\n  SkillMatchScore,\n  SkillScript,\n  SkillReference,\n  SkillAsset,\n  SKILL_FILE_NAME,\n  SCRIPTS_DIR_NAME,\n  REFERENCES_DIR_NAME,\n  ASSETS_DIR_NAME,\n  REFERENCE_FILE_NAME,\n  EXAMPLES_FILE_NAME,\n  KEYWORDS_FILE_NAME,\n  DEFAULT_SKILL_VERSION,\n  DEFAULT_SKILL_ENABLED,\n  DEFAULT_USER_INVOCABLE,\n} from './types'\nimport { parseSkillFile, generateSkillId, detectScriptType } from './parser'\nimport { validateSkillYamlMetadata } from './validator'\nimport { readTextFile, readDir, BaseDirectory, DirEntry } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions } from '@/lib/workspace'\nimport { exists } from '@tauri-apps/plugin-fs'\n\n// ============================================================================\n// SkillManager 类\n// ============================================================================\n\n/**\n * Skill 管理器类\n *\n * 负责：\n * - 发现和加载 Skills\n * - 注册和注销 Skills\n * - 匹配相关 Skills\n * - 验证 Skill 格式\n * - 管理脚本、参考文档和资源文件\n */\nclass SkillManager {\n  private skills: Map<string, SkillContent> = new Map()\n  private skillFiles: Map<string, SkillFileInfo> = new Map()\n  private initialized = false\n\n  // ========================================================================\n  // 初始化\n  // ========================================================================\n\n  /**\n   * 初始化 Skill 管理器\n   * 加载所有可用的 Skills\n   */\n  async initialize(): Promise<void> {\n    if (this.initialized) {\n      return\n    }\n\n    await this.discoverSkills()\n    this.initialized = true\n  }\n\n  /**\n   * 重新加载所有 Skills\n   */\n  async reload(): Promise<void> {\n    this.skills.clear()\n    this.skillFiles.clear()\n    this.initialized = false\n    await this.initialize()\n  }\n\n  // ========================================================================\n  // 发现和加载\n  // ========================================================================\n\n  /**\n   * 发现并加载所有 Skills\n   */\n  async discoverSkills(): Promise<void> {\n    // 加载工作区 Skills\n    await this.discoverProjectSkills()\n\n    // 加载全局 Skills\n    await this.discoverGlobalSkills()\n  }\n\n  /**\n   * 发现工作区 Skills\n   */\n  private async discoverProjectSkills(): Promise<void> {\n    try {\n      const skillsDirExists = await this.directoryExists('skills', 'project')\n      if (!skillsDirExists) {\n        return\n      }\n\n      const skillDirs = await this.listSkillDirectories('skills', 'project')\n\n      for (const dirName of skillDirs) {\n        try {\n          await this.loadSkillFromDirectory('skills', dirName, 'project')\n        } catch (error) {\n          console.error(`加载工作区 Skill 失败: ${dirName}`, error)\n        }\n      }\n    } catch (error) {\n      console.error('发现工作区 Skills 失败:', error)\n    }\n  }\n\n  /**\n   * 发现全局 Skills\n   */\n  private async discoverGlobalSkills(): Promise<void> {\n    try {\n      const skillsDirExists = await this.directoryExists('skills', 'global')\n      if (!skillsDirExists) {\n        return\n      }\n\n      const skillDirs = await this.listSkillDirectories('skills', 'global')\n\n      for (const dirName of skillDirs) {\n        try {\n          await this.loadSkillFromDirectory('skills', dirName, 'global')\n        } catch (error) {\n          console.error(`加载全局 Skill 失败: ${dirName}`, error)\n        }\n      }\n    } catch (error) {\n      console.error('发现全局 Skills 失败:', error)\n    }\n  }\n\n  /**\n   * 从目录加载单个 Skill\n   */\n  private async loadSkillFromDirectory(\n    baseDir: string,\n    dirName: string,\n    scope: SkillScope\n  ): Promise<void> {\n    const skillId = generateSkillId(dirName)\n    const skillDirPath = `${baseDir}/${dirName}`\n    const skillFilePath = `${skillDirPath}/${SKILL_FILE_NAME}`\n\n    // 检查 SKILL.md 是否存在\n    const fileExists = await this.fileExists(skillFilePath, scope)\n    if (!fileExists) {\n      this.skillFiles.set(skillId, {\n        id: skillId,\n        directory: skillDirPath,\n        mainFile: skillFilePath,\n        hasScriptsDir: false,\n        hasReferencesDir: false,\n        hasAssetsDir: false,\n        isValid: false,\n        error: 'SKILL.md 文件不存在',\n      })\n      return\n    }\n\n    // 读取 SKILL.md 内容\n    const content = await this.readFileContent(skillFilePath, scope)\n\n    // 解析 Skill 文件\n    const parsed = parseSkillFile(content)\n\n    // 验证元数据\n    const validation = validateSkillYamlMetadata(parsed.metadata)\n    if (!validation.valid) {\n      this.skillFiles.set(skillId, {\n        id: skillId,\n        directory: skillDirPath,\n        mainFile: skillFilePath,\n        hasScriptsDir: false,\n        hasReferencesDir: false,\n        hasAssetsDir: false,\n        isValid: false,\n        error: validation.errors.map((e) => e.message).join('; '),\n      })\n      return\n    }\n\n    // 检查官方规范目录结构\n    const hasScriptsDir = await this.directoryExists(\n      `${skillDirPath}/${SCRIPTS_DIR_NAME}`,\n      scope\n    )\n    const hasReferencesDir = await this.directoryExists(\n      `${skillDirPath}/${REFERENCES_DIR_NAME}`,\n      scope\n    )\n    const hasAssetsDir = await this.directoryExists(\n      `${skillDirPath}/${ASSETS_DIR_NAME}`,\n      scope\n    )\n\n    // 向后兼容：检查旧的根目录文件\n    const hasReferenceFile = await this.fileExists(\n      `${skillDirPath}/${REFERENCE_FILE_NAME}`,\n      scope\n    )\n    const hasExamplesFile = await this.fileExists(\n      `${skillDirPath}/${EXAMPLES_FILE_NAME}`,\n      scope\n    )\n    const hasKeywordsFile = await this.fileExists(\n      `${skillDirPath}/${KEYWORDS_FILE_NAME}`,\n      scope\n    )\n\n    // 加载脚本 (scripts/)\n    const scripts: SkillScript[] = []\n    if (hasScriptsDir) {\n      const scriptFiles = await this.loadScriptsFromDirectory(\n        `${skillDirPath}/${SCRIPTS_DIR_NAME}`,\n        scope\n      )\n      scripts.push(...scriptFiles)\n    }\n\n    // 加载参考文档 (references/)\n    const references: SkillReference[] = []\n    if (hasReferencesDir) {\n      const referenceFiles = await this.loadReferencesFromDirectory(\n        `${skillDirPath}/${REFERENCES_DIR_NAME}`,\n        scope\n      )\n      references.push(...referenceFiles)\n    }\n\n    // 向后兼容：加载根目录的 REFERENCE.md\n    if (hasReferenceFile && !hasReferencesDir) {\n      const refContent = await this.readFileContent(\n        `${skillDirPath}/${REFERENCE_FILE_NAME}`,\n        scope\n      )\n      references.push({\n        name: REFERENCE_FILE_NAME,\n        path: REFERENCE_FILE_NAME,\n        description: 'Legacy reference file (consider moving to references/)',\n      })\n      // 将旧格式内容附加到指令中\n      parsed.content += '\\n\\n---\\n\\n## 参考文档 (Legacy)\\n\\n' + refContent\n    }\n\n    // 加载资源文件 (assets/)\n    const assets: SkillAsset[] = []\n    if (hasAssetsDir) {\n      const assetFiles = await this.loadAssetsFromDirectory(\n        `${skillDirPath}/${ASSETS_DIR_NAME}`,\n        scope\n      )\n      assets.push(...assetFiles)\n    }\n\n    // 向后兼容：加载 KEYWORDS.md\n    if (hasKeywordsFile) {\n      const keywordsContent = await this.readFileContent(\n        `${skillDirPath}/${KEYWORDS_FILE_NAME}`,\n        scope\n      )\n      parsed.content += '\\n\\n---\\n\\n## 关键词 (Legacy)\\n\\n' + keywordsContent\n    }\n\n    // 加载根目录下的所有 .md 文件（排除 SKILL.md 自身）\n    const rootMdFiles = await this.loadRootMdFiles(skillDirPath, scope)\n    references.push(...rootMdFiles)\n\n    // 构建 Skill 内容\n    const now = Date.now()\n    const skill: SkillContent = {\n      metadata: {\n        id: skillId,\n        name: parsed.metadata.name,\n        description: parsed.metadata.description,\n        license: parsed.metadata.license,\n        compatibility: parsed.metadata.compatibility,\n        metadata: parsed.metadata.metadata,\n        version: parsed.metadata.version || parsed.metadata.metadata?.version || DEFAULT_SKILL_VERSION,\n        author: parsed.metadata.author || parsed.metadata.metadata?.author,\n        scope,\n        model: parsed.metadata.model,\n        allowedTools: Array.isArray(parsed.metadata.allowedTools)\n          ? parsed.metadata.allowedTools\n          : typeof parsed.metadata.allowedTools === 'string'\n            ? parsed.metadata.allowedTools.split(/\\s+/).filter(v => v.length > 0)\n            : undefined,\n        userInvocable: parsed.metadata.userInvocable ?? DEFAULT_USER_INVOCABLE,\n        enabled: DEFAULT_SKILL_ENABLED,\n        createdAt: now,\n        updatedAt: now,\n      },\n      instructions: parsed.content,\n      scripts,\n      references,\n      assets,\n    }\n\n    // 注册 Skill\n    this.registerSkill(skill)\n\n    // 记录文件信息\n    this.skillFiles.set(skillId, {\n      id: skillId,\n      directory: skillDirPath,\n      mainFile: skillFilePath,\n      hasScriptsDir,\n      hasReferencesDir,\n      hasAssetsDir,\n      hasReferenceFile,\n      hasExamplesFile,\n      hasKeywordsFile,\n      isValid: true,\n      scriptCount: scripts.length,\n      referenceCount: references.length,\n      assetCount: assets.length,\n    })\n  }\n\n  /**\n   * 从 scripts/ 目录加载脚本（支持第一层子目录递归）\n   * 限制递归深度为 1，避免扫描过深\n   */\n  private async loadScriptsFromDirectory(\n    scriptsDir: string,\n    scope: SkillScope,\n    basePath: string = '',\n    depth: number = 0\n  ): Promise<SkillScript[]> {\n    const scripts: SkillScript[] = []\n\n    // 限制递归深度为 1（只扫描 scripts 下的直接子目录）\n    const maxDepth = 1\n\n    try {\n      let entries: DirEntry[]\n\n      if (scope === 'global') {\n        entries = await readDir(scriptsDir, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(scriptsDir)\n        if (options.baseDir) {\n          entries = await readDir(options.path, { baseDir: options.baseDir })\n        } else {\n          entries = await readDir(options.path)\n        }\n      }\n\n      for (const entry of entries) {\n        // 跳过隐藏文件和目录\n        if (entry.name.startsWith('.')) {\n          continue\n        }\n\n        const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name\n\n        if (entry.isFile) {\n          // 检测脚本类型\n          const scriptType = detectScriptType(entry.name)\n          if (!scriptType) {\n            // 非脚本文件，静默跳过（减少日志噪音）\n            continue\n          }\n\n          scripts.push({\n            name: relativePath, // 使用相对路径作为脚本名称（如 \"office/unpack.py\"）\n            path: `${scriptsDir}/${relativePath}`,\n            type: scriptType,\n          })\n        } else if (entry.isDirectory && depth < maxDepth) {\n          // 递归加载子目录中的脚本（深度限制为 1）\n          const subScripts = await this.loadScriptsFromDirectory(\n            `${scriptsDir}/${entry.name}`,\n            scope,\n            relativePath,\n            depth + 1\n          )\n          scripts.push(...subScripts)\n        }\n      }\n    } catch (error) {\n      console.error(`[SkillManager] 读取脚本目录失败: ${scriptsDir}`, error)\n    }\n\n    return scripts\n  }\n\n  /**\n   * 从 references/ 目录加载参考文档\n   */\n  private async loadReferencesFromDirectory(\n    referencesDir: string,\n    scope: SkillScope\n  ): Promise<SkillReference[]> {\n    const references: SkillReference[] = []\n\n    try {\n      let entries: DirEntry[]\n\n      if (scope === 'global') {\n        entries = await readDir(referencesDir, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(referencesDir)\n        if (options.baseDir) {\n          entries = await readDir(options.path, { baseDir: options.baseDir })\n        } else {\n          entries = await readDir(options.path)\n        }\n      }\n\n      for (const entry of entries) {\n        // 跳过隐藏文件和目录\n        if (entry.name.startsWith('.')) {\n          continue\n        }\n\n        // 只处理 markdown 文件\n        if (entry.isFile && entry.name.endsWith('.md')) {\n          references.push({\n            name: entry.name,\n            path: `${referencesDir}/${entry.name}`,\n          })\n        }\n      }\n    } catch (error) {\n      console.error(`读取参考文档目录失败: ${referencesDir}`, error)\n    }\n\n    return references\n  }\n\n  /**\n   * 从 assets/ 目录加载资源文件\n   */\n  private async loadAssetsFromDirectory(\n    assetsDir: string,\n    scope: SkillScope\n  ): Promise<SkillAsset[]> {\n    const assets: SkillAsset[] = []\n\n    try {\n      let entries: DirEntry[]\n\n      if (scope === 'global') {\n        entries = await readDir(assetsDir, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(assetsDir)\n        if (options.baseDir) {\n          entries = await readDir(options.path, { baseDir: options.baseDir })\n        } else {\n          entries = await readDir(options.path)\n        }\n      }\n\n      for (const entry of entries) {\n        // 跳过隐藏文件和目录\n        if (entry.name.startsWith('.')) {\n          continue\n        }\n\n        if (entry.isFile) {\n          const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase()\n          const assetPath = `${assetsDir}/${entry.name}`\n\n          // 根据扩展名确定资源类型\n          let type: SkillAsset['type'] = 'other'\n          if (['.json', '.yaml', '.yml', '.toml'].includes(ext)) {\n            type = 'data'\n          } else if (\n            ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)\n          ) {\n            type = 'image'\n          } else if (\n            ['.md', '.txt', '.template'].includes(ext) ||\n            entry.name.includes('template')\n          ) {\n            type = 'template'\n          }\n\n          assets.push({\n            name: entry.name,\n            path: assetPath,\n            type,\n          })\n        }\n      }\n    } catch (error) {\n      console.error(`读取资源目录失败: ${assetsDir}`, error)\n    }\n\n    return assets\n  }\n\n  /**\n   * 从 skill 根目录加载额外的 .md 文件（如 editing.md, pptxgenjs.md）\n   * 支持加载 skill 根目录下的所有 .md 文件（排除 SKILL.md 自身）\n   */\n  private async loadRootMdFiles(\n    skillDirPath: string,\n    scope: SkillScope\n  ): Promise<SkillReference[]> {\n    const references: SkillReference[] = []\n\n    try {\n      let entries: DirEntry[]\n\n      if (scope === 'global') {\n        entries = await readDir(skillDirPath, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(skillDirPath)\n        if (options.baseDir) {\n          entries = await readDir(options.path, { baseDir: options.baseDir })\n        } else {\n          entries = await readDir(options.path)\n        }\n      }\n\n      for (const entry of entries) {\n        // 只处理 .md 文件，排除 SKILL.md\n        if (\n          entry.isFile &&\n          entry.name.endsWith('.md') &&\n          entry.name !== SKILL_FILE_NAME\n        ) {\n          references.push({\n            name: entry.name, // 如 \"pptxgenjs.md\", \"editing.md\"\n            path: entry.name,\n            description: `Additional reference file: ${entry.name}`,\n          })\n        }\n      }\n    } catch (error) {\n      console.error(`[SkillManager] 读取根目录 .md 文件失败: ${skillDirPath}`, error)\n    }\n\n    return references\n  }\n\n  // ========================================================================\n  // 注册和注销\n  // ========================================================================\n\n  /**\n   * 注册 Skill\n   */\n  registerSkill(skill: SkillContent): void {\n    this.skills.set(skill.metadata.id, skill)\n  }\n\n  /**\n   * 注销 Skill\n   */\n  unregisterSkill(skillId: string): void {\n    this.skills.delete(skillId)\n    this.skillFiles.delete(skillId)\n  }\n\n  // ========================================================================\n  // 获取 Skills\n  // ========================================================================\n\n  /**\n   * 获取所有 Skills\n   */\n  getAllSkills(): SkillContent[] {\n    return Array.from(this.skills.values())\n  }\n\n  /**\n   * 获取指定作用域的 Skills\n   */\n  getSkillsByScope(scope: SkillScope): SkillContent[] {\n    return this.getAllSkills().filter(\n      (skill) => skill.metadata.scope === scope\n    )\n  }\n\n  /**\n   * 获取所有已加载的 Skills（移除启用/禁用判断，直接返回所有）\n   */\n  async getEnabledSkills(): Promise<SkillContent[]> {\n    // 直接返回所有已加载的 Skills，不进行启用/禁用过滤\n    const allSkills = this.getAllSkills()\n    return allSkills\n  }\n\n  /**\n   * 获取可用户调用的 Skills\n   */\n  getUserInvocableSkills(): SkillContent[] {\n    return this.getAllSkills().filter(\n      (skill) => skill.metadata.userInvocable !== false\n    )\n  }\n\n  /**\n   * 根据 ID 获取 Skill\n   */\n  getSkill(id: string): SkillContent | undefined {\n    return this.skills.get(id)\n  }\n\n  /**\n   * 检查 Skill 是否存在\n   */\n  hasSkill(id: string): boolean {\n    return this.skills.has(id)\n  }\n\n  /**\n   * 获取 Skill 的脚本\n   */\n  getSkillScripts(skillId: string): SkillScript[] {\n    const skill = this.getSkill(skillId)\n    return skill?.scripts || []\n  }\n\n  /**\n   * 获取 Skill 的参考文档\n   */\n  getSkillReferences(skillId: string): SkillReference[] {\n    const skill = this.getSkill(skillId)\n    return skill?.references || []\n  }\n\n  /**\n   * 获取 Skill 的资源文件\n   */\n  getSkillAssets(skillId: string): SkillAsset[] {\n    const skill = this.getSkill(skillId)\n    return skill?.assets || []\n  }\n\n  // ========================================================================\n  // 匹配相关\n  // ========================================================================\n\n  /**\n   * 根据用户输入匹配相关 Skills\n   *\n   * @param userInput - 用户输入\n   * @param maxResults - 最大返回结果数\n   * @returns 匹配的 Skills 列表（按匹配分数排序）\n   */\n  async matchRelevantSkills(\n    userInput: string,\n    maxResults: number = 3\n  ): Promise<SkillContent[]> {\n    const enabledSkills = await this.getEnabledSkills()\n    const scores: SkillMatchScore[] = []\n\n    for (const skill of enabledSkills) {\n      const score = this.calculateMatchScore(skill, userInput)\n      if (score.score > 0) {\n        scores.push(score)\n      }\n    }\n\n    // 按分数降序排序\n    scores.sort((a, b) => b.score - a.score)\n\n    const result = scores\n      .slice(0, maxResults)\n      .map((score) => score.skill)\n\n    return result\n  }\n\n  /**\n   * 计算 Skill 与用户输入的匹配分数\n   */\n  private calculateMatchScore(\n    skill: SkillContent,\n    userInput: string\n  ): SkillMatchScore {\n    const description = skill.metadata.description.toLowerCase()\n    const input = userInput.toLowerCase()\n    const reasons: string[] = []\n    let score = 0\n\n    // 完全匹配\n    if (description.includes(input)) {\n      score += 1\n      reasons.push('描述包含用户输入')\n    }\n\n    // 关键词匹配\n    const keywords = this.extractKeywords(description)\n    const matchedKeywords = keywords.filter((keyword) =>\n      input.includes(keyword)\n    )\n    if (matchedKeywords.length > 0) {\n      score += matchedKeywords.length * 0.5\n      reasons.push(`匹配关键词: ${matchedKeywords.join(', ')}`)\n    }\n\n    // 语义相似度（简化版）\n    if (this.hasSemanticOverlap(description, input)) {\n      score += 0.3\n      reasons.push('语义相关')\n    }\n\n    return {\n      skill,\n      score: Math.min(score, 1), // 限制在 0-1 之间\n      reasons,\n    }\n  }\n\n  /**\n   * 从描述中提取关键词\n   */\n  private extractKeywords(description: string): string[] {\n    const keywords: string[] = []\n\n    // 提取各种引号中的内容作为关键词（支持中文引号）\n    const quoteRegex = /[\"\"\"\"「」『』\\[\\]（）()](.+?)[\"\"\"\"「」『』\\[\\]（）()]/g\n    let match\n    while ((match = quoteRegex.exec(description)) !== null) {\n      keywords.push(match[1].toLowerCase())\n    }\n\n    // 提取\"当...时使用\"或\"当...时调用\"中的内容\n    const triggerRegex = /当(?:.*?)?(.+?)(?:时使用|时调用|时)/gi\n    let triggerMatch\n    while ((triggerMatch = triggerRegex.exec(description)) !== null) {\n      keywords.push(triggerMatch[1].toLowerCase())\n    }\n\n    // 提取\"关于...的内容\"中的关键词\n    const aboutRegex = /关于[\"\"\"\"「」『』\\[\\]（）()]?([^\"\"\"\"「」『』\\[\\]（）()\\s]+)[\"\"\"\"「」『』\\[\\]】()]?的内容/g\n    let aboutMatch\n    while ((aboutMatch = aboutRegex.exec(description)) !== null) {\n      keywords.push(aboutMatch[1].toLowerCase())\n    }\n\n    // 提取描述中的所有中文词汇（2-4个字的词）\n    const chineseWords = description.match(/[\\u4e00-\\u9fa5]{2,4}/g) || []\n    keywords.push(...chineseWords)\n\n    // 提取描述中的所有英文单词\n    const englishWords = description.match(/[a-zA-Z]{2,}/g) || []\n    keywords.push(...englishWords.map(w => w.toLowerCase()))\n\n    return keywords\n  }\n\n  /**\n   * 检查语义重叠\n   */\n  private hasSemanticOverlap(text1: string, text2: string): boolean {\n    const words1 = new Set(text1.split(/\\s+/))\n    const words2 = new Set(text2.split(/\\s+/))\n\n    let overlap = 0\n    for (const word of words2) {\n      if (words1.has(word)) {\n        overlap++\n      }\n    }\n\n    // 至少 20% 的词重叠\n    return overlap / words2.size >= 0.2\n  }\n\n  // ========================================================================\n  // 验证\n  // ========================================================================\n\n  /**\n   * 验证 Skill 内容\n   */\n  validateSkill(content: string): { valid: boolean; errors: string[] } {\n    try {\n      const parsed = parseSkillFile(content)\n      const validation = validateSkillYamlMetadata(parsed.metadata)\n\n      return {\n        valid: validation.valid,\n        errors: validation.errors.map((e) => e.message),\n      }\n    } catch (error) {\n      return {\n        valid: false,\n        errors: [error instanceof Error ? error.message : String(error)],\n      }\n    }\n  }\n\n  // ========================================================================\n  // 辅助方法\n  // ========================================================================\n\n  /**\n   * 检查文件是否存在\n   */\n  private async fileExists(\n    path: string,\n    scope: SkillScope\n  ): Promise<boolean> {\n    try {\n      if (scope === 'global') {\n        return await exists(path, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(path)\n        if (options.baseDir) {\n          return await exists(options.path, { baseDir: options.baseDir })\n        }\n        return await exists(options.path)\n      }\n    } catch {\n      return false\n    }\n  }\n\n  /**\n   * 检查目录是否存在\n   */\n  private async directoryExists(\n    path: string,\n    scope: SkillScope\n  ): Promise<boolean> {\n    return this.fileExists(path, scope)\n  }\n\n  /**\n   * 列出 Skill 子目录\n   */\n  private async listSkillDirectories(\n    baseDir: string,\n    scope: SkillScope\n  ): Promise<string[]> {\n    const dirs: string[] = []\n\n    try {\n      let entries: DirEntry[]\n\n      if (scope === 'global') {\n        entries = await readDir(baseDir, { baseDir: BaseDirectory.AppData })\n      } else {\n        const options = await getFilePathOptions(baseDir)\n        if (options.baseDir) {\n          entries = await readDir(options.path, { baseDir: options.baseDir })\n        } else {\n          entries = await readDir(options.path)\n        }\n      }\n\n      for (const entry of entries) {\n        if (entry.isDirectory && !entry.name.startsWith('.')) {\n          dirs.push(entry.name)\n        }\n      }\n    } catch (error) {\n      console.error(`列出目录失败: ${baseDir}`, error)\n    }\n\n    return dirs\n  }\n\n  /**\n   * 读取文件内容\n   */\n  private async readFileContent(\n    path: string,\n    scope: SkillScope\n  ): Promise<string> {\n    if (scope === 'global') {\n      return await readTextFile(path, { baseDir: BaseDirectory.AppData })\n    } else {\n      const options = await getFilePathOptions(path)\n      if (options.baseDir) {\n        return await readTextFile(options.path, { baseDir: options.baseDir })\n      }\n      return await readTextFile(options.path)\n    }\n  }\n\n  /**\n   * 获取 Skill 文件信息\n   */\n  getSkillFileInfo(id: string): SkillFileInfo | undefined {\n    return this.skillFiles.get(id)\n  }\n\n  /**\n   * 获取所有 Skill 文件信息\n   */\n  getAllSkillFileInfo(): SkillFileInfo[] {\n    return Array.from(this.skillFiles.values())\n  }\n}\n\n// ============================================================================\n// 单例导出\n// ============================================================================\n\nexport const skillManager = new SkillManager()\n\n// 重置管理器（主要用于测试）\nexport function resetSkillManager(): void {\n  ;(skillManager as any).skills.clear()\n  ;(skillManager as any).skillFiles.clear()\n  ;(skillManager as any).initialized = false\n}\n"
  },
  {
    "path": "src/lib/skills/parser.ts",
    "content": "/**\n * SKILL.md 文件解析器\n *\n * 解析 SKILL.md 文件，提取 YAML 前置元数据和 Markdown 内容。\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\nimport {\n  ParsedSkillFile,\n  SkillYamlMetadata,\n  ScriptType,\n  SCRIPT_EXTENSIONS,\n  SCRIPT_SHEBANG,\n} from './types'\n\n// ============================================================================\n// 解析函数\n// ============================================================================\n\n/**\n * 解析 SKILL.md 文件内容\n *\n * @param content - SKILL.md 文件的原始内容\n * @returns 解析后的 Skill 文件对象\n */\nexport function parseSkillFile(content: string): ParsedSkillFile {\n  // 检查是否包含 YAML 前置\n  if (!content.startsWith('---')) {\n    return {\n      metadata: {\n        name: '',\n        description: '',\n      },\n      content: content.trim(),\n      rawContent: content,\n    }\n  }\n\n  // 提取 YAML 前置部分\n  const yamlEnd = content.indexOf('\\n---', 3)\n  if (yamlEnd === -1) {\n    throw new Error('Invalid SKILL.md: YAML frontmatter not properly closed')\n  }\n\n  const yamlContent = content.slice(3, yamlEnd).trim()\n  const markdownContent = content.slice(yamlEnd + 4).trim()\n\n  // 解析 YAML 元数据\n  const metadata = parseYamlMetadata(yamlContent)\n\n  return {\n    metadata,\n    content: markdownContent,\n    rawContent: content,\n  }\n}\n\n/**\n * 解析 YAML 元数据\n *\n * @param yamlContent - YAML 格式的元数据内容\n * @returns 解析后的元数据对象\n */\nfunction parseYamlMetadata(yamlContent: string): SkillYamlMetadata {\n  const metadata: SkillYamlMetadata = {\n    name: '',\n    description: '',\n  }\n\n  const lines = yamlContent.split('\\n')\n  let inMetadataSection = false\n\n  for (const line of lines) {\n    const trimmed = line.trim()\n\n    // 跳过空行和注释\n    if (!trimmed || trimmed.startsWith('#')) {\n      continue\n    }\n\n    // 检查是否进入 metadata 部分\n    if (trimmed === 'metadata:') {\n      inMetadataSection = true\n      continue\n    }\n\n    // 如果在 metadata 部分中，处理缩进的键值对\n    if (inMetadataSection) {\n      if (trimmed.startsWith('- ')) {\n        // 列表项，跳过（由下面的逻辑处理）\n      } else if (trimmed.startsWith('[')) {\n        // 数组格式，跳过\n      } else {\n        const metadataIndent = line.match(/^(\\s+)/)?.[1]?.length || 0\n        if (metadataIndent > 0) {\n          // metadata 下的子项\n          const colonIndex = trimmed.indexOf(':')\n          if (colonIndex > 0) {\n            const key = trimmed.slice(0, colonIndex).trim()\n            const value = trimmed.slice(colonIndex + 1).trim()\n\n            if (!metadata.metadata) {\n              metadata.metadata = {}\n            }\n            metadata.metadata[key] = parseValue(value)\n            continue\n          }\n        } else {\n          // 退出 metadata 部分\n          inMetadataSection = false\n        }\n      }\n    }\n\n    // 检测 allowedTools 的 YAML 列表格式: allowedTools: 后跟 - item 行\n    if ((trimmed.startsWith('allowedTools:') || trimmed.startsWith('allowed-tools:')) &&\n        !trimmed.includes(': ') && !trimmed.includes(':[')) {\n      // 这是列表格式的开始，收集后续的 - item 行\n      const tools: string[] = []\n      const currentLineIndex = lines.indexOf(line)\n      const currentIndent = line.match(/^(\\s+)/)?.[1]?.length || 0\n\n      // 收集后续行\n      for (let i = currentLineIndex + 1; i < lines.length; i++) {\n        const nextLine = lines[i]\n        const nextTrimmed = nextLine.trim()\n\n        // 检查是否退出（无缩进或非列表项）\n        const nextIndent = nextLine.match(/^(\\s+)/)?.[1]?.length || 0\n        if (nextTrimmed && nextIndent <= currentIndent && !nextTrimmed.startsWith('-')) {\n          break\n        }\n\n        if (nextTrimmed.startsWith('- ')) {\n          const tool = nextTrimmed.replace(/^- /, '').trim().replace(/['\"]/g, '')\n          if (tool) {\n            tools.push(tool)\n          }\n        }\n      }\n\n      if (tools.length > 0) {\n        metadata.allowedTools = tools\n        continue\n      }\n    }\n\n    // 解析顶级键值对\n    const colonIndex = trimmed.indexOf(':')\n    if (colonIndex === -1) {\n      continue\n    }\n\n    const key = trimmed.slice(0, colonIndex).trim()\n    const value = trimmed.slice(colonIndex + 1).trim()\n\n    switch (key) {\n      case 'name':\n        metadata.name = value\n        break\n      case 'description':\n        metadata.description = value\n        break\n      case 'license':\n        metadata.license = value\n        break\n      case 'compatibility':\n        metadata.compatibility = value\n        break\n      case 'metadata':\n        // metadata: 开始标记，将在下一次迭代处理\n        inMetadataSection = true\n        if (!metadata.metadata) {\n          metadata.metadata = {}\n        }\n        break\n      case 'allowed-tools':\n      case 'allowedTools':\n        metadata.allowedTools = parseAllowedTools(value)\n        break\n      case 'version':\n        metadata.version = value\n        // 同时存入 metadata 中（向后兼容）\n        if (!metadata.metadata) {\n          metadata.metadata = {}\n        }\n        metadata.metadata.version = value\n        break\n      case 'author':\n        metadata.author = value\n        // 同时存入 metadata 中（向后兼容）\n        if (!metadata.metadata) {\n          metadata.metadata = {}\n        }\n        metadata.metadata.author = value\n        break\n      case 'model':\n        metadata.model = value\n        break\n      case 'userInvocable':\n        metadata.userInvocable = parseBoolean(value)\n        break\n    }\n  }\n\n  return metadata\n}\n\n/**\n * 解析值（去除引号）\n */\nfunction parseValue(value: string): string {\n  value = value.trim()\n  if ((value.startsWith('\"') && value.endsWith('\"')) ||\n      (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n    return value.slice(1, -1)\n  }\n  return value\n}\n\n/**\n * 解析 allowedTools 字段\n *\n * 支持多种格式：\n * - 空格分隔: allowed-tools: Bash Read Write\n * - 数组格式: allowedTools: [tool1, tool2]\n * - YAML 列表格式:\n *   allowedTools:\n *     - tool1\n *     - tool2\n *\n * @param value - allowedTools 的值\n * @returns 工具名称数组\n */\nfunction parseAllowedTools(value: string): string[] {\n  value = value.trim()\n\n  // 空格分隔格式 (官方规范)\n  if (!value.startsWith('[') && !value.startsWith('-')) {\n    return value.split(/\\s+/).filter(v => v.length > 0)\n  }\n\n  // 数组格式: [tool1, tool2]\n  if (value.startsWith('[') && value.endsWith(']')) {\n    return value\n      .slice(1, -1)\n      .split(',')\n      .map((v) => v.trim().replace(/['\"]/g, ''))\n      .filter((v) => v.length > 0)\n  }\n\n  // 单个值\n  if (value.length > 0 && !value.startsWith('-')) {\n    return [value.replace(/['\"]/g, '')]\n  }\n\n  return []\n}\n\n/**\n * 解析布尔值\n *\n * @param value - 布尔值的字符串表示\n * @returns 解析后的布尔值\n */\nfunction parseBoolean(value: string): boolean {\n  const normalized = value.toLowerCase().trim()\n  return normalized === 'true' || normalized === 'yes' || normalized === '1'\n}\n\n// ============================================================================\n// 生成函数\n// ============================================================================\n\n/**\n * 将 Skill 内容序列化为 SKILL.md 文件格式\n *\n * @param metadata - Skill 元数据\n * @param instructions - 指令内容\n * @returns SKILL.md 文件内容\n */\nexport function serializeSkillFile(\n  metadata: SkillYamlMetadata,\n  instructions: string\n): string {\n  const yamlLines: string[] = ['---']\n\n  // 必填字段\n  yamlLines.push(`name: ${metadata.name}`)\n  yamlLines.push(`description: ${metadata.description}`)\n\n  // 可选字段 (官方规范)\n  if (metadata.license) {\n    yamlLines.push(`license: ${metadata.license}`)\n  }\n\n  if (metadata.compatibility) {\n    yamlLines.push(`compatibility: ${metadata.compatibility}`)\n  }\n\n  // metadata 字段\n  if (metadata.metadata && Object.keys(metadata.metadata).length > 0) {\n    yamlLines.push(`metadata:`)\n    for (const [key, value] of Object.entries(metadata.metadata)) {\n      yamlLines.push(`  ${key}: ${value}`)\n    }\n  }\n\n  // allowedTools (官方规范使用空格分隔)\n  if (metadata.allowedTools && metadata.allowedTools.length > 0) {\n    const toolsValue = Array.isArray(metadata.allowedTools)\n      ? metadata.allowedTools.join(' ')\n      : metadata.allowedTools\n    yamlLines.push(`allowed-tools: ${toolsValue}`)\n  }\n\n  // 扩展字段 (向后兼容)\n  if (metadata.version && !metadata.metadata?.version) {\n    yamlLines.push(`version: ${metadata.version}`)\n  }\n\n  if (metadata.author && !metadata.metadata?.author) {\n    yamlLines.push(`author: ${metadata.author}`)\n  }\n\n  if (metadata.model) {\n    yamlLines.push(`model: ${metadata.model}`)\n  }\n\n  if (metadata.userInvocable !== undefined) {\n    yamlLines.push(`userInvocable: ${metadata.userInvocable}`)\n  }\n\n  yamlLines.push('---')\n\n  // Markdown 内容\n  const content = yamlLines.join('\\n') + '\\n\\n' + instructions.trim() + '\\n'\n\n  return content\n}\n\n// ============================================================================\n// 辅助函数\n// ============================================================================\n\n/**\n * 从目录名生成 Skill ID\n *\n * @param directoryName - Skill 目录名\n * @returns Skill ID (kebab-case)\n */\nexport function generateSkillId(directoryName: string): string {\n  return directoryName\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '-')\n    .replace(/^-+|-+$/g, '')\n}\n\n/**\n * 验证 Skill ID 格式\n *\n * 官方规范要求：\n * - 1-64 字符\n * - 只能包含小写字母、数字和连字符\n * - 不能以连字符开头或结尾\n * - 不能包含连续的连字符\n *\n * @param id - Skill ID\n * @returns 是否有效\n */\nexport function isValidSkillId(id: string): boolean {\n  if (id.length < 1 || id.length > 64) {\n    return false\n  }\n  if (id.startsWith('-') || id.endsWith('-')) {\n    return false\n  }\n  if (id.includes('--')) {\n    return false\n  }\n  return /^[a-z0-9-]+$/.test(id)\n}\n\n/**\n * 验证 name 字段格式 (官方规范)\n *\n * @param name - Skill 名称\n * @returns 是否有效\n */\nexport function isValidSkillName(name: string): boolean {\n  if (name.length < 1 || name.length > 64) {\n    return false\n  }\n  if (name.startsWith('-') || name.endsWith('-')) {\n    return false\n  }\n  if (name.includes('--')) {\n    return false\n  }\n  // 只能包含 unicode 小写字母数字和连字符\n  return /^[\\p{Ll}0-9-]+$/u.test(name)\n}\n\n/**\n * 验证 description 字段格式 (官方规范)\n *\n * @param description - Skill 描述\n * @returns 是否有效\n */\nexport function isValidSkillDescription(description: string): boolean {\n  return description.length >= 1 && description.length <= 1024\n}\n\n/**\n * 检测脚本类型\n *\n * @param filename - 脚本文件名\n * @param content - 脚本内容 (可选，用于 shebang 检测)\n * @returns 脚本类型或 null\n */\nexport function detectScriptType(filename: string, content?: string): ScriptType | null {\n  const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase()\n\n  // 通过扩展名检测\n  for (const [type, extensions] of Object.entries(SCRIPT_EXTENSIONS)) {\n    if (extensions.includes(ext)) {\n      return type as ScriptType\n    }\n  }\n\n  // 通过 shebang 检测\n  if (content) {\n    const firstLine = content.split('\\n')[0].trim()\n    for (const [type, shebangs] of Object.entries(SCRIPT_SHEBANG)) {\n      if (shebangs.some(s => firstLine.startsWith(s))) {\n        return type as ScriptType\n      }\n    }\n  }\n\n  return null\n}\n\n/**\n * 从 SKILL.md 内容中提取引用链接\n *\n * 查找 Markdown 格式的链接: [text](path.md)\n * 支持官方规范的相对路径引用\n *\n * @param content - Markdown 内容\n * @returns 引用文件路径数组\n */\nexport function extractReferenceLinks(content: string): string[] {\n  const linkRegex = /\\[([^\\]]+)\\]\\(([^)]+\\.md)\\)/g\n  const links: string[] = []\n\n  let match\n  while ((match = linkRegex.exec(content)) !== null) {\n    links.push(match[2])\n  }\n\n  return links\n}\n\n/**\n * 从 SKILL.md 内容中提取脚本引用\n *\n * 查找类似 \"Run the extraction script: scripts/extract.py\" 的文本\n *\n * @param content - Markdown 内容\n * @returns 脚本路径数组\n */\nexport function extractScriptReferences(content: string): string[] {\n  const patterns = [\n    /scripts\\/[^\\s\\)]+/g,\n    /`scripts\\/[^\\s`]+`/g,\n  ]\n\n  const scripts: string[] = []\n\n  for (const pattern of patterns) {\n    let match\n    while ((match = pattern.exec(content)) !== null) {\n      const scriptPath = match[0].replace(/`/g, '')\n      scripts.push(scriptPath)\n    }\n  }\n\n  return scripts\n}\n"
  },
  {
    "path": "src/lib/skills/path-utils.ts",
    "content": "/**\n * Skills 路径解析工具模块\n *\n * 提供统一的路径解析函数，用于处理 global 和 project scope 的 skill 目录路径。\n */\n\nimport { appDataDir } from '@tauri-apps/api/path'\nimport { getFilePathOptions } from '@/lib/workspace'\nimport type { SkillScope } from './types'\n\n/**\n * 解析 skill 目录的完整路径\n *\n * @param skillDir - skill 目录相对路径（如 \"skills/pdf\"）\n * @param scope - skill 作用域（global 或 project）\n * @returns 完整的文件系统路径\n */\nexport async function resolveSkillDirectory(\n  skillDir: string,\n  scope: SkillScope\n): Promise<string> {\n  if (scope === 'global') {\n    // For global skills, fileInfo.directory is a relative path under AppData\n    const appDataPath = await appDataDir()\n    return `${appDataPath}/${skillDir}`\n  }\n\n  // For project skills\n  const options = await getFilePathOptions(skillDir)\n\n  if (options.baseDir) {\n    // 默认 workspace: options.path 是 \"article/skills/xxx\"\n    // 需要使用 appDataDir 作为基础路径\n    const appDataPath = await appDataDir()\n    // 注意：这里已经包含了 article 前缀，不需要再拼接\n    return `${appDataPath}/${options.path}`\n  } else {\n    // 自定义 workspace: options.path 已经是完整路径\n    return options.path\n  }\n}\n\n/**\n * 解析脚本的相对路径\n *\n * 处理两种情况：\n * 1. 绝对路径格式: \"skills/pdf/scripts/example.py\"\n * 2. 相对路径格式: \"scripts/example.py\" 或 \"office/unpack.py\"\n *\n * @param scriptPath - 脚本路径\n * @param skillBaseName - skill 目录名（如 \"pdf\"）\n * @returns 相对于 skill 目录的脚本路径\n */\nexport function resolveScriptRelativePath(\n  scriptPath: string,\n  skillBaseName: string\n): string {\n  // 尝试多种模式来提取相对路径\n  const patterns = [\n    // 匹配 \"skills/{skillName}/\" 开头的部分\n    new RegExp(`^skills/${skillBaseName}/`),\n    // 匹配 \"skills/{skillName}\" 开头的部分（不带末尾斜杠）\n    new RegExp(`^skills/${skillBaseName}$`),\n    // 匹配任意 \"skills/xxx/\" 开头的部分（更通用的模式）\n    new RegExp(`^skills/[^/]+/`),\n  ]\n\n  for (const pattern of patterns) {\n    const relativePath = scriptPath.replace(pattern, '')\n    if (relativePath !== scriptPath) {\n      return relativePath\n    }\n  }\n\n  // 如果没有匹配任何模式，假设已经是相对路径\n  return scriptPath\n}\n\n/**\n * 转义 shell 命令参数\n * 处理空格、引号等特殊字符\n *\n * @param arg - 原始参数\n * @returns 转义后的参数\n */\nexport function escapeShellArg(arg: string): string {\n  // 简单安全字符直接返回\n  if (/^[a-zA-Z0-9_./:-]+$/.test(arg)) {\n    return arg\n  }\n\n  // 统一使用单引号包裹，并安全转义内部单引号\n  return `'${arg.replace(/'/g, `'\\\"'\\\"'`)}'`\n}\n\n/**\n * 构建 shell 命令\n * 包含工作目录切换和参数转义\n *\n * @param workingDirectory - 工作目录（skill 目录）\n * @param moduleDir - 模块目录（用于查找 node_modules）\n * @param command - 命令\n * @param args - 参数数组\n * @returns 完整的 shell 命令字符串\n */\nexport function buildShellCommand(\n  workingDirectory: string,\n  moduleDir: string,\n  command: string,\n  args: string[]\n): string {\n  const escapedArgs = args.map(escapeShellArg)\n\n  // 检查是否所有参数都是绝对路径\n  // 如果是绝对路径，需要 cd 到脚本所在目录，但用 NODE_PATH 指向模块目录\n  const allAbsolutePaths = args.every(arg => arg.startsWith('/'))\n\n  if (allAbsolutePaths) {\n    // 获取第一个绝对路径的目录作为工作目录\n    // 例如：/path/to/article/generate.js -> /path/to/article\n    const scriptDir = args[0].substring(0, args[0].lastIndexOf('/'))\n    // 使用脚本所在目录作为工作目录，但用 NODE_PATH 指向 skill 的 node_modules\n    return `cd \"${scriptDir}\" && NODE_PATH=\"${moduleDir}/node_modules\" ${command} ${escapedArgs.join(' ')}`\n  }\n\n  return `cd \"${workingDirectory}\" && ${command} ${escapedArgs.join(' ')}`\n}\n"
  },
  {
    "path": "src/lib/skills/runtime-paths.ts",
    "content": "export interface ClassifiedSkillScriptPath {\n  kind: 'generated-runtime-script' | 'runtime-script' | 'builtin-skill-script' | 'other'\n  normalizedArg: string\n}\n\nfunction hasScriptExtension(filePath: string): boolean {\n  return /\\.(py|js|mjs|cjs|sh|bash)$/i.test(filePath)\n}\n\nexport function classifySkillScriptPath(arg: string): ClassifiedSkillScriptPath {\n  const normalized = arg.replace(/\\\\/g, '/')\n\n  const runtimeMatch = normalized.match(/^skills\\/[^/]+\\/scripts\\/[^/]+\\/([^/]+)$/)\n  if (runtimeMatch) {\n    return {\n      kind: 'generated-runtime-script',\n      normalizedArg: runtimeMatch[1],\n    }\n  }\n\n  if (normalized.startsWith('scripts/')) {\n    return {\n      kind: 'builtin-skill-script',\n      normalizedArg: normalized,\n    }\n  }\n\n  if (!normalized.includes('/') && hasScriptExtension(normalized)) {\n    return {\n      kind: 'runtime-script',\n      normalizedArg: normalized,\n    }\n  }\n\n  return {\n    kind: 'other',\n    normalizedArg: normalized,\n  }\n}\n"
  },
  {
    "path": "src/lib/skills/runtime.ts",
    "content": "import { appDataDir } from '@tauri-apps/api/path'\nimport { BaseDirectory, exists, mkdir, readDir, rename } from '@tauri-apps/plugin-fs'\nimport { Command } from '@tauri-apps/plugin-shell'\nimport { skillManager } from './manager'\nimport { buildShellCommand, resolveSkillDirectory } from './path-utils'\nimport { detectPythonCommand, ensureDependencyForCommand } from './dependency-installer'\nimport { getFilePathOptions } from '@/lib/workspace'\nimport { classifySkillScriptPath } from './runtime-paths'\n\nexport interface SkillRuntimeContext {\n  skillId: string\n  skillDir: string\n  runtimeDir: string\n  outputDir: string\n  appArticleDir: string\n  fsBaseDir?: BaseDirectory\n  skillDirFsPath: string\n  runtimeDirFsPath: string\n  outputDirFsPath: string\n}\n\nexport interface SkillExecutionRequest {\n  skillId: string\n  command: string\n  args?: string[]\n  timeout?: number\n}\n\nexport interface SkillExecutionData {\n  exit_code: number\n  execution_time_ms: number\n  working_directory: string\n  runtime_directory: string\n  output_directory: string\n  stdout: string\n  stderr: string\n  dependency_installed?: string\n  output_files?: string[]\n  timeout?: boolean\n}\n\nexport interface SkillExecutionOutcome {\n  success: boolean\n  error?: string\n  message: string\n  data: SkillExecutionData\n}\n\nconst OUTPUT_FILE_EXTENSIONS = new Set([\n  'pptx',\n  'pdf',\n  'docx',\n  'xlsx',\n  'png',\n  'jpg',\n  'jpeg',\n  'gif',\n  'svg',\n  'md',\n  'json',\n  'csv',\n  'txt',\n])\n\nconst SCRIPT_FILE_EXTENSIONS = new Set(['js', 'mjs', 'cjs', 'py', 'sh', 'bash'])\n\nfunction getExtension(filePath: string): string {\n  const fileName = filePath.split('/').pop() || filePath\n  const index = fileName.lastIndexOf('.')\n  return index === -1 ? '' : fileName.slice(index + 1).toLowerCase()\n}\n\nfunction isScriptLikeFile(filePath: string): boolean {\n  return SCRIPT_FILE_EXTENSIONS.has(getExtension(filePath))\n}\n\nfunction isOutputLikeFile(filePath: string): boolean {\n  return OUTPUT_FILE_EXTENSIONS.has(getExtension(filePath))\n}\n\nfunction isAbsolutePath(filePath: string): boolean {\n  return filePath.startsWith('/')\n}\n\nfunction isSkillBuiltInPath(filePath: string): boolean {\n  return filePath.startsWith('scripts/') || filePath.startsWith('scripts\\\\')\n}\n\nfunction isSafeRelativePath(filePath: string): boolean {\n  return !filePath.startsWith('..') && !isAbsolutePath(filePath)\n}\n\nfunction toPosixPath(filePath: string): string {\n  return filePath.replace(/\\\\/g, '/')\n}\n\nasync function pathExists(filePath: string, baseDir?: BaseDirectory): Promise<boolean> {\n  try {\n    return baseDir ? await exists(filePath, { baseDir }) : await exists(filePath)\n  } catch {\n    return false\n  }\n}\n\nasync function ensureDir(dir: string, baseDir?: BaseDirectory): Promise<void> {\n  if (!(await pathExists(dir, baseDir))) {\n    if (baseDir) {\n      await mkdir(dir, { baseDir, recursive: true })\n    } else {\n      await mkdir(dir, { recursive: true })\n    }\n  }\n}\n\nasync function resolveWritableRuntimeDir(\n  fileInfoDirectory: string\n): Promise<{ runtimeDirPath: string; runtimeDirFsPath: string; baseDir?: BaseDirectory }> {\n  const appDataPath = await appDataDir()\n  const fallbackOptions = await getFilePathOptions(`${fileInfoDirectory}/runtime`)\n  await ensureDir(fallbackOptions.path, fallbackOptions.baseDir)\n\n  return {\n    runtimeDirPath: fallbackOptions.baseDir\n      ? `${appDataPath}/${fallbackOptions.path}`\n      : fallbackOptions.path,\n    runtimeDirFsPath: fallbackOptions.path,\n    baseDir: fallbackOptions.baseDir,\n  }\n}\n\nasync function resolveContext(skillId: string): Promise<SkillRuntimeContext> {\n  const skill = skillManager.getSkill(skillId)\n  if (!skill) {\n    throw new Error(`Skill not found: ${skillId}`)\n  }\n\n  const fileInfo = skillManager.getSkillFileInfo(skillId)\n  if (!fileInfo) {\n    throw new Error(`Cannot determine Skill directory for: ${skillId}`)\n  }\n\n  const skillDir = await resolveSkillDirectory(fileInfo.directory, skill.metadata.scope)\n  const appDataPath = await appDataDir()\n  const appArticleDir = `${appDataPath.replace(/\\/$/, '')}/article`\n  const outputDir = `${appArticleDir}/outputs/${skillId}`\n  const runtimeResolution = await resolveWritableRuntimeDir(fileInfo.directory)\n  const runtimeDir = runtimeResolution.runtimeDirPath\n  const outputDirOptions = await getFilePathOptions(`outputs/${skillId}`)\n  const skillDirOptions = await getFilePathOptions(fileInfo.directory)\n  const fsBaseDir = runtimeResolution.baseDir ?? outputDirOptions.baseDir ?? skillDirOptions.baseDir\n\n  await ensureDir(outputDirOptions.path, outputDirOptions.baseDir)\n\n  return {\n    skillId,\n    skillDir,\n    runtimeDir,\n    outputDir,\n    appArticleDir,\n    fsBaseDir,\n    skillDirFsPath: skillDirOptions.path,\n    runtimeDirFsPath: runtimeResolution.runtimeDirFsPath,\n    outputDirFsPath: outputDirOptions.path,\n  }\n}\n\nasync function normalizeArg(arg: string, context: SkillRuntimeContext): Promise<string> {\n  if (!arg || typeof arg !== 'string') {\n    return arg\n  }\n\n  if (isAbsolutePath(arg)) {\n    return arg\n  }\n\n  const classified = classifySkillScriptPath(arg)\n  const normalized = classified.normalizedArg\n\n  if (isSkillBuiltInPath(normalized)) {\n    return normalized\n  }\n\n  if (!normalized.includes('/') && isScriptLikeFile(normalized)) {\n    const runtimeCandidate = `${context.runtimeDir}/${normalized}`\n    const runtimeCandidateFsPath = `${context.runtimeDirFsPath}/${normalized}`\n    if (await pathExists(runtimeCandidateFsPath, context.fsBaseDir)) {\n      return runtimeCandidate\n    }\n\n    const skillRootCandidate = `${context.skillDir}/${normalized}`\n    const skillRootCandidateFsPath = `${context.skillDirFsPath}/${normalized}`\n    if (await pathExists(skillRootCandidateFsPath, context.fsBaseDir)) {\n      return skillRootCandidate\n    }\n\n    return runtimeCandidate\n  }\n\n  if (normalized.startsWith('article/')) {\n    return `${context.appArticleDir}/${normalized.replace(/^article\\//, '')}`\n  }\n\n  if (normalized.startsWith('../article/')) {\n    return `${context.appArticleDir}/${normalized.replace(/^\\.\\.\\/article\\//, '')}`\n  }\n\n  if (!normalized.includes('/') && isOutputLikeFile(normalized)) {\n    const articleCandidate = `${context.appArticleDir}/${normalized}`\n    const articleCandidateFsPath = `article/${normalized}`.replace(/^article\\/article\\//, 'article/')\n    if (await pathExists(articleCandidateFsPath, BaseDirectory.AppData)) {\n      return articleCandidate\n    }\n\n    return `${context.outputDir}/${normalized}`\n  }\n\n  if (isSafeRelativePath(normalized)) {\n    const runtimeCandidate = `${context.runtimeDir}/${normalized}`\n    const runtimeCandidateFsPath = `${context.runtimeDirFsPath}/${normalized}`\n    if (await pathExists(runtimeCandidateFsPath, context.fsBaseDir)) {\n      return runtimeCandidate\n    }\n  }\n\n  return normalized\n}\n\nfunction parseCommand(command: string, args: string[]): { cmd: string; cmdArgs: string[] } {\n  if (command.includes(' ')) {\n    const commandParts = command.trim().split(/\\s+/)\n    return {\n      cmd: commandParts[0],\n      cmdArgs: [...commandParts.slice(1), ...args],\n    }\n  }\n\n  return {\n    cmd: command,\n    cmdArgs: [...args],\n  }\n}\n\nasync function normalizeExecutionPlan(\n  command: string,\n  args: string[]\n): Promise<{ command: string; args: string[] }> {\n  if (command === 'python' || command === 'python3') {\n    const pythonCommand = (await detectPythonCommand(command)) || command\n\n    if (args[0] === '-m' && args[1] === 'markitdown' && args[2]) {\n      return {\n        command: pythonCommand,\n        args: [\n          '-c',\n          [\n            'from markitdown import MarkItDown',\n            'import sys',\n            'result = MarkItDown().convert(sys.argv[1])',\n            'print(getattr(result, \"text_content\", str(result)))',\n          ].join('; '),\n          args[2],\n        ],\n      }\n    }\n\n    return {\n      command: pythonCommand,\n      args,\n    }\n  }\n\n  if (command === 'pip' || command === 'pip3') {\n    const pythonCommand = (await detectPythonCommand('python3')) || 'python3'\n    return {\n      command: pythonCommand,\n      args: ['-m', 'pip', ...args],\n    }\n  }\n\n  return {\n    command,\n    args,\n  }\n}\n\nfunction determineWorkingDirectory(\n  context: SkillRuntimeContext,\n  command: string,\n  processedArgs: string[]\n): string {\n  if ((command === 'node' || command === 'python' || command === 'python3' || command === 'bash' || command === 'sh') && processedArgs.length > 0) {\n    const candidateScript = processedArgs.find((arg) => !arg.startsWith('-') && isScriptLikeFile(arg))\n    if (candidateScript && candidateScript.startsWith(`${context.runtimeDir}/`)) {\n      return context.runtimeDir\n    }\n  }\n\n  return context.skillDir\n}\n\nasync function snapshotOutputFiles(context: SkillRuntimeContext): Promise<Set<string>> {\n  const snapshot = new Set<string>()\n  const entries = context.fsBaseDir\n    ? await readDir(context.outputDirFsPath, { baseDir: context.fsBaseDir })\n    : await readDir(context.outputDirFsPath)\n\n  for (const entry of entries) {\n    if (entry.isFile && entry.name && isOutputLikeFile(entry.name) && !isScriptLikeFile(entry.name)) {\n      snapshot.add(`outputs/${context.skillId}/${entry.name}`)\n    }\n  }\n\n  return snapshot\n}\n\nasync function collectGeneratedOutputs(context: SkillRuntimeContext, previousOutputs: Set<string>): Promise<string[]> {\n  const movedFiles: string[] = []\n  const seenTargets = new Set<string>()\n\n  async function moveOutputFile(fullPathFs: string, relativeFromRuntime: string): Promise<void> {\n    const normalizedRelativePath = toPosixPath(relativeFromRuntime).replace(/^\\/+/, '')\n    if (!normalizedRelativePath || !isOutputLikeFile(normalizedRelativePath) || isScriptLikeFile(normalizedRelativePath)) {\n      return\n    }\n\n    const targetPathFs = `${context.outputDirFsPath}/${normalizedRelativePath}`.replace(/\\/+/g, '/')\n    const outputRelativePath = `outputs/${context.skillId}/${normalizedRelativePath}`.replace(/\\/+/g, '/')\n\n    if (seenTargets.has(outputRelativePath)) {\n      return\n    }\n\n    seenTargets.add(outputRelativePath)\n\n    try {\n      if (fullPathFs === targetPathFs) {\n        movedFiles.push(outputRelativePath)\n        return\n      }\n\n      const targetDirFsPath = targetPathFs.slice(0, targetPathFs.lastIndexOf('/'))\n      await ensureDir(targetDirFsPath, context.fsBaseDir)\n\n      if (context.fsBaseDir) {\n        await rename(fullPathFs, targetPathFs, {\n          oldPathBaseDir: context.fsBaseDir,\n          newPathBaseDir: context.fsBaseDir,\n        })\n      } else {\n        await rename(fullPathFs, targetPathFs)\n      }\n\n      movedFiles.push(outputRelativePath)\n    } catch (error) {\n      console.error('[skill-runtime] Failed to move generated output', {\n        source: fullPathFs,\n        target: targetPathFs,\n        error: String(error),\n      })\n    }\n  }\n\n  async function walkRuntime(currentDirFsPath: string, currentRelativeFromRuntime = ''): Promise<void> {\n    const entries = context.fsBaseDir\n      ? await readDir(currentDirFsPath, { baseDir: context.fsBaseDir })\n      : await readDir(currentDirFsPath)\n\n    for (const entry of entries) {\n      if (!entry.name) continue\n\n      const relativeFromRuntime = currentRelativeFromRuntime\n        ? `${currentRelativeFromRuntime}/${entry.name}`\n        : entry.name\n      const fullPathFs = `${context.runtimeDirFsPath}/${relativeFromRuntime}`.replace(/\\/+/g, '/')\n\n      if (entry.isDirectory) {\n        await walkRuntime(fullPathFs, relativeFromRuntime)\n        continue\n      }\n\n      if (!entry.isFile) {\n        continue\n      }\n\n      await moveOutputFile(fullPathFs, relativeFromRuntime)\n    }\n  }\n\n  await walkRuntime(context.runtimeDirFsPath)\n\n  const existingOutputEntries = context.fsBaseDir\n    ? await readDir(context.outputDirFsPath, { baseDir: context.fsBaseDir })\n    : await readDir(context.outputDirFsPath)\n\n  for (const entry of existingOutputEntries) {\n    if (entry.isFile && entry.name && isOutputLikeFile(entry.name) && !isScriptLikeFile(entry.name)) {\n      const outputRelativePath = `outputs/${context.skillId}/${entry.name}`\n      if (!seenTargets.has(outputRelativePath) && !previousOutputs.has(outputRelativePath)) {\n        movedFiles.push(outputRelativePath)\n      }\n    }\n  }\n\n  return Array.from(new Set(movedFiles))\n}\n\nexport async function executeSkillRuntime(\n  request: SkillExecutionRequest\n): Promise<SkillExecutionOutcome> {\n  const startTime = Date.now()\n  const executionTimeout = Math.min(Math.max(request.timeout || 60000, 1000), 300000)\n\n  const context = await resolveContext(request.skillId)\n  const parsed = parseCommand(request.command, Array.isArray(request.args) ? request.args : [])\n  const normalizedPlan = await normalizeExecutionPlan(parsed.cmd, parsed.cmdArgs)\n  const normalizedCommand = normalizedPlan.command\n  const processedArgs: string[] = []\n  const existingOutputs = await snapshotOutputFiles(context)\n\n  for (const arg of normalizedPlan.args) {\n    processedArgs.push(await normalizeArg(arg, context))\n  }\n\n  const envPrefix = [\n    `SKILL_OUTPUT_DIR=\"${context.outputDir}\"`,\n    `SKILL_RUNTIME_DIR=\"${context.runtimeDir}\"`,\n    `SKILL_ROOT_DIR=\"${context.skillDir}\"`,\n    `NOTEGEN_OUTPUT_DIR=\"${context.outputDir}\"`,\n  ].join(' ')\n  const workingDirectory = determineWorkingDirectory(context, normalizedCommand, processedArgs)\n\n  const shellCommand = parsed.cmd === 'bash' && processedArgs[0] === '-c'\n    ? `cd \"${workingDirectory}\" && ${envPrefix} ${processedArgs.slice(1).join(' ')}`\n    : `cd \"${workingDirectory}\" && ${envPrefix} ${buildShellCommand(workingDirectory, workingDirectory, normalizedCommand, processedArgs).replace(`cd \"${workingDirectory}\" && `, '')}`\n\n  const stdoutChunks: string[] = []\n  const stderrChunks: string[] = []\n\n  async function runShellCommand(): Promise<{ code: number; stdout: string; stderr: string }> {\n    const process = Command.create('bash', ['-c', shellCommand])\n\n    process.stdout.on('data', (line: string) => {\n      stdoutChunks.push(line)\n    })\n\n    process.stderr.on('data', (line: string) => {\n      stderrChunks.push(line)\n    })\n\n    const execution = process.execute()\n    const result = await Promise.race([\n      execution,\n      new Promise<never>((_, reject) =>\n        setTimeout(() => reject(new Error(`Script execution timed out after ${executionTimeout}ms`)), executionTimeout)\n      ),\n    ])\n\n    return {\n      code: result.code ?? -1,\n      stdout: stdoutChunks.join('') + (result.stdout || ''),\n      stderr: stderrChunks.join('') + (result.stderr || ''),\n    }\n  }\n\n  try {\n    let result = await runShellCommand()\n    let installedDependency: string | undefined\n\n    if (result.code !== 0) {\n      const installResult = await ensureDependencyForCommand({\n        stderr: result.stderr,\n        command: normalizedCommand,\n        workingDirectory: context.skillDir,\n      })\n\n      if (installResult?.success) {\n        installedDependency = installResult.installed\n        stdoutChunks.length = 0\n        stderrChunks.length = 0\n        result = await runShellCommand()\n      }\n    }\n\n    const outputFiles = result.code === 0 ? await collectGeneratedOutputs(context, existingOutputs) : []\n    const executionTime = Date.now() - startTime\n\n    return {\n      success: result.code === 0,\n      error: result.code === 0 ? undefined : (result.stderr || `命令执行失败，退出码: ${result.code}`),\n      data: {\n        exit_code: result.code,\n        execution_time_ms: executionTime,\n        working_directory: workingDirectory,\n        runtime_directory: context.runtimeDir,\n        output_directory: context.outputDir,\n        stdout: result.stdout,\n        stderr: result.stderr,\n        dependency_installed: installedDependency,\n        output_files: outputFiles,\n      },\n      message: result.code === 0\n        ? `Command executed successfully (exit code: ${result.code}, time: ${executionTime}ms).${installedDependency ? `\\n\\nAuto-installed dependency: ${installedDependency}` : ''}${outputFiles.length > 0 ? `\\n\\nOutput files:\\n${outputFiles.map(file => `- ${file}`).join('\\n')}` : ''}\\n\\nOutput:\\n${result.stdout || '(no output)'}`\n        : `Command failed with exit code ${result.code} (time: ${executionTime}ms).${installedDependency ? `\\n\\nAuto-installed dependency: ${installedDependency}` : ''}\\n\\n${result.stderr ? `Error:\\n${result.stderr}` : 'No error message'}${result.stdout ? `\\n\\nOutput:\\n${result.stdout}` : ''}`,\n    }\n  } catch (error) {\n    const executionTime = Date.now() - startTime\n    const errorMessage = error instanceof Error ? error.message : String(error)\n\n    return {\n      success: false,\n      error: `Script execution error: ${errorMessage}`,\n      message: `Script execution failed: ${errorMessage}`,\n      data: {\n        exit_code: -1,\n        execution_time_ms: executionTime,\n        working_directory: workingDirectory,\n        runtime_directory: context.runtimeDir,\n        output_directory: context.outputDir,\n        stdout: stdoutChunks.join(''),\n        stderr: stderrChunks.join(''),\n        timeout: errorMessage.includes('timed out'),\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/skills/types.ts",
    "content": "/**\n * Skills 类型定义\n *\n * Skills 是可重用的 AI 能力包，让 AI 助手能够根据任务自动应用特定的行为模式。\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\n// ============================================================================\n// 核心类型\n// ============================================================================\n\n/**\n * Skill 作用域\n */\nexport type SkillScope = 'global' | 'project'\n\n/**\n * Skill 脚本类型\n */\nexport type ScriptType = 'python' | 'bash' | 'javascript' | 'node' | 'shell'\n\n/**\n * Skill 脚本文件\n */\nexport interface SkillScript {\n  name: string                  // 脚本文件名\n  path: string                  // 相对路径 (scripts/script-name.py)\n  type: ScriptType              // 脚本类型\n  description?: string          // 脚本描述\n}\n\n/**\n * Skill 参考文件\n */\nexport interface SkillReference {\n  name: string                  // 参考文件名\n  path: string                  // 相对路径 (references/reference.md)\n  description?: string          // 参考内容描述\n}\n\n/**\n * Skill 资源文件 (assets/)\n */\nexport interface SkillAsset {\n  name: string                  // 资源文件名\n  path: string                  // 相对路径 (assets/template.json)\n  type: 'template' | 'image' | 'data' | 'other'\n  description?: string          // 资源描述\n}\n\n/**\n * Skill 元数据 (符合官方规范)\n */\nexport interface SkillMetadata {\n  // 基本信息 (官方规范必填字段)\n  id: string                    // 唯一标识 (skill-name, 必须与目录名匹配)\n  name: string                  // Skill 名称 (1-64字符, 小写字母数字和连字符)\n  description: string           // 功能描述 (1-1024字符, 用于 AI 匹配)\n\n  // 官方规范可选字段\n  license?: string              // 许可证名称或引用的许可证文件\n  compatibility?: string        // 环境要求 (1-500字符)\n  metadata?: Record<string, string>  // 额外的元数据键值对\n\n  // 扩展字段 (应用特定)\n  version?: string              // 版本号 (存储在 metadata.version 中)\n  author?: string               // 作者 (存储在 metadata.author 中)\n\n  // 存储位置\n  scope: SkillScope             // 作用域：全局(应用数据目录) 或 项目(工作区)\n\n  // 执行配置 (扩展字段)\n  model?: string                // 指定使用的模型\n  allowedTools?: string[]       // 允许使用的工具 (无需权限确认)\n\n  // 可见性控制 (扩展字段)\n  userInvocable?: boolean       // 是否在斜杠菜单显示\n\n  // 状态 (扩展字段)\n  enabled?: boolean             // 是否启用\n  createdAt: number\n  updatedAt: number\n\n  // 依赖声明\n  dependencies?: SkillDependency[]\n}\n\n/**\n * Skill 内容\n */\nexport interface SkillContent {\n  metadata: SkillMetadata\n  instructions: string          // Markdown 格式的指令 (SKILL.md 内容)\n\n  // 官方规范支持的目录结构\n  scripts: SkillScript[]        // scripts/ 目录中的脚本\n  references: SkillReference[]  // references/ 目录中的参考文档\n  assets: SkillAsset[]          // assets/ 目录中的静态资源\n}\n\n// ============================================================================\n// 解析相关类型\n// ============================================================================\n\n/**\n * SKILL.md 文件的 YAML 前置元数据 (符合官方规范)\n */\nexport interface SkillYamlMetadata {\n  // 必填字段\n  name: string\n  description: string\n\n  // 可选字段 (官方规范)\n  license?: string\n  compatibility?: string\n  metadata?: Record<string, string>\n  allowedTools?: string[] | string  // 空格分隔的工具列表或数组\n\n  // 扩展字段 (向后兼容)\n  version?: string\n  author?: string\n  model?: string\n  userInvocable?: boolean\n\n  // 依赖声明\n  dependencies?: SkillDependency[]\n}\n\n/**\n * Skill 依赖声明\n */\nexport interface SkillDependency {\n  name: string           // 依赖名称，如 \"requests\" 或 \"lodash\"\n  version?: string      // 版本要求，如 \">=2.0.0\"（可选）\n  manager: 'pip' | 'npm' | 'yarn' | 'pnpm'  // 包管理器\n}\n\n/**\n * 解析后的 SKILL.md 内容\n */\nexport interface ParsedSkillFile {\n  metadata: SkillYamlMetadata\n  content: string               // Markdown 内容（不包含 YAML 前置）\n  rawContent: string            // 原始文件内容\n}\n\n// ============================================================================\n// 验证相关类型\n// ============================================================================\n\n/**\n * 验证结果\n */\nexport interface ValidationResult {\n  valid: boolean\n  errors: ValidationError[]\n  warnings: ValidationWarning[]\n}\n\n/**\n * 验证错误\n */\nexport interface ValidationError {\n  field: string\n  message: string\n  severity: 'error'\n}\n\n/**\n * 验证警告\n */\nexport interface ValidationWarning {\n  field: string\n  message: string\n  severity: 'warning'\n}\n\n// ============================================================================\n// 执行相关类型\n// ============================================================================\n\n/**\n * 脚本执行结果\n */\nexport interface ScriptExecutionResult {\n  success: boolean\n  scriptName: string\n  output?: string               // 脚本输出\n  error?: string                // 错误信息\n  exitCode?: number             // 退出码\n  executionTime: number          // 执行耗时 (ms)\n}\n\n/**\n * Skill 执行结果\n */\nexport interface SkillExecutionResult {\n  success: boolean\n  skillId: string\n  result?: string\n  error?: string\n  toolsUsed: string[]\n  scriptsUsed: string[]          // 使用的脚本列表\n  executionTime: number\n}\n\n/**\n * Skill 执行记录\n */\nexport interface SkillExecutionRecord {\n  id: string\n  skillId: string\n  skillName: string\n  userInput: string\n  result: SkillExecutionResult\n  timestamp: number\n}\n\n// ============================================================================\n// 存储相关类型\n// ============================================================================\n\n/**\n * Skill 文件信息\n */\nexport interface SkillFileInfo {\n  id: string                    // 从目录名派生\n  directory: string             // Skill 目录路径\n  mainFile: string              // SKILL.md 文件路径\n\n  // 官方规范目录结构\n  hasScriptsDir: boolean        // 是否有 scripts/ 目录\n  hasReferencesDir: boolean     // 是否有 references/ 目录\n  hasAssetsDir: boolean         // 是否有 assets/ 目录\n\n  // 向后兼容 (已弃用，但保留以支持旧结构)\n  hasReferenceFile?: boolean    // 根目录是否有 REFERENCE.md\n  hasExamplesFile?: boolean     // 根目录是否有 EXAMPLES.md\n  hasKeywordsFile?: boolean     // 根目录是否有 KEYWORDS.md\n\n  isValid: boolean              // 是否有效 Skill\n  error?: string                // 错误信息\n\n  // 统计信息\n  scriptCount?: number          // 脚本数量\n  referenceCount?: number       // 参考文件数量\n  assetCount?: number           // 资源文件数量\n}\n\n// ============================================================================\n// 工具函数类型\n// ============================================================================\n\n/**\n * Skill 匹配分数\n */\nexport interface SkillMatchScore {\n  skill: SkillContent\n  score: number                 // 匹配分数 (0-1)\n  reasons: string[]             // 匹配原因\n}\n\n// ============================================================================\n// 常量\n// ============================================================================\n\n/**\n * Skill 文件名常量\n */\nexport const SKILL_FILE_NAME = 'SKILL.md'\n\n/**\n * 官方规范目录名称\n */\nexport const SCRIPTS_DIR_NAME = 'scripts'\nexport const REFERENCES_DIR_NAME = 'references'\nexport const ASSETS_DIR_NAME = 'assets'\n\n/**\n * 向后兼容的文件名 (已弃用)\n * @deprecated 使用 references/ 目录代替\n */\nexport const REFERENCE_FILE_NAME = 'REFERENCE.md'\nexport const EXAMPLES_FILE_NAME = 'EXAMPLES.md'\nexport const KEYWORDS_FILE_NAME = 'KEYWORDS.md'\n\n/**\n * Skills 目录名称\n */\nexport const SKILLS_DIR_NAME = 'skills'\n\n/**\n * 默认元数据值\n */\nexport const DEFAULT_SKILL_VERSION = '1.0.0'\nexport const DEFAULT_SKILL_ENABLED = true\nexport const DEFAULT_USER_INVOCABLE = true\n\n/**\n * 支持的脚本类型及其扩展名\n */\nexport const SCRIPT_EXTENSIONS: Record<ScriptType, string[]> = {\n  python: ['.py'],\n  bash: ['.sh', '.bash'],\n  javascript: ['.js', '.mjs'],\n  node: ['.js'],\n  shell: ['.sh'],\n}\n\n/**\n * 脚本类型的 shebang 标记\n */\nexport const SCRIPT_SHEBANG: Record<ScriptType, string[]> = {\n  python: ['#!/usr/bin/env python', '#!/usr/bin/python'],\n  bash: ['#!/bin/bash', '#!/usr/bin/env bash'],\n  javascript: ['#!/usr/bin/env node'],\n  node: ['#!/usr/bin/env node'],\n  shell: ['#!/bin/sh'],\n}\n"
  },
  {
    "path": "src/lib/skills/utils.ts",
    "content": "/**\n * Skills 相关工具函数\n *\n * 用于处理 Skills 文件夹的特殊逻辑\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\nimport {\n  SKILLS_DIR_NAME,\n  SCRIPTS_DIR_NAME,\n  REFERENCES_DIR_NAME,\n  ASSETS_DIR_NAME,\n  SKILL_FILE_NAME,\n} from './types'\n\n/**\n * 检查文件夹是否是 Skills 文件夹\n */\nexport function isSkillsFolder(folderName: string): boolean {\n  return folderName === SKILLS_DIR_NAME\n}\n\n/**\n * 检查路径是否在 Skills 文件夹内\n */\nexport function isInSkillsFolder(path: string): boolean {\n  const normalizedPath = path.replace(/\\\\/g, '/')\n  return (\n    normalizedPath.includes(`/${SKILLS_DIR_NAME}/`) ||\n    normalizedPath.startsWith(`${SKILLS_DIR_NAME}/`)\n  )\n}\n\n/**\n * 检查路径是否在 Skill 子目录中 (scripts/, references/, assets/)\n */\nexport function isInSkillSubdirectory(path: string): {\n  inSkill: boolean\n  skillId: string | null\n  subdirectory: 'scripts' | 'references' | 'assets' | null\n} {\n  const normalizedPath = path.replace(/\\\\/g, '/')\n\n  // 检查 scripts/\n  const scriptsMatch = normalizedPath.match(\n    new RegExp(`${SKILLS_DIR_NAME}/([^/]+)/${SCRIPTS_DIR_NAME}/`)\n  )\n  if (scriptsMatch) {\n    return {\n      inSkill: true,\n      skillId: scriptsMatch[1],\n      subdirectory: 'scripts',\n    }\n  }\n\n  // 检查 references/\n  const referencesMatch = normalizedPath.match(\n    new RegExp(`${SKILLS_DIR_NAME}/([^/]+)/${REFERENCES_DIR_NAME}/`)\n  )\n  if (referencesMatch) {\n    return {\n      inSkill: true,\n      skillId: referencesMatch[1],\n      subdirectory: 'references',\n    }\n  }\n\n  // 检查 assets/\n  const assetsMatch = normalizedPath.match(\n    new RegExp(`${SKILLS_DIR_NAME}/([^/]+)/${ASSETS_DIR_NAME}/`)\n  )\n  if (assetsMatch) {\n    return {\n      inSkill: true,\n      skillId: assetsMatch[1],\n      subdirectory: 'assets',\n    }\n  }\n\n  return {\n    inSkill: false,\n    skillId: null,\n    subdirectory: null,\n  }\n}\n\n/**\n * 获取 Skills 文件夹的特殊图标组件\n */\nexport function getSkillsFolderIcon(): string {\n  return 'Sparkles'  // lucide-react 图标名称\n}\n\n/**\n * 判断是否应该隐藏知识库相关选项\n */\nexport function shouldHideKnowledgeBaseOptions(folderName: string, filePath: string): boolean {\n  return isSkillsFolder(folderName) || isInSkillsFolder(filePath)\n}\n\n/**\n * 从右键菜单项中移除知识库相关选项\n */\nexport function filterKnowledgeBaseMenuItems(\n  menuItems: any[],\n  folderName: string,\n  filePath: string\n): any[] {\n  if (!shouldHideKnowledgeBaseOptions(folderName, filePath)) {\n    return menuItems\n  }\n\n  // 过滤掉知识库相关的菜单项\n  return menuItems.filter((item: any) => {\n    const itemId = item.props?.id || item.id || ''\n    return !itemId.includes('knowledge-base')\n  })\n}\n\n/**\n * 提取 Skill ID 从路径中\n * 例如: \"skills/code-reviewer\" -> \"code-reviewer\"\n */\nexport function extractSkillIdFromPath(path: string): string | null {\n  const normalizedPath = path.replace(/\\\\/g, '/')\n\n  // 检查是否在 skills 文件夹下\n  const skillsFolderPattern = new RegExp(\n    `${SKILLS_DIR_NAME}/([^/]+)`\n  )\n  const match = normalizedPath.match(skillsFolderPattern)\n\n  if (match && match[1]) {\n    return match[1]\n  }\n\n  return null\n}\n\n/**\n * 检查路径是否是 Skill 子文件夹\n * 例如: \"skills/code-reviewer\" -> true\n *       \"skills\" -> false\n *       \"other/code-reviewer\" -> false\n */\nexport function isSkillSubfolder(path: string): boolean {\n  return extractSkillIdFromPath(path) !== null\n}\n\n/**\n * 检查文件是否是 SKILL.md\n */\nexport function isSkillFile(fileName: string): boolean {\n  return fileName === SKILL_FILE_NAME\n}\n\n/**\n * 获取 Skill 目录结构信息\n * 返回 Skill 目录的完整结构描述\n */\nexport function getSkillDirectoryStructure(): {\n  description: string\n  structure: Record<string, { description: string; required: boolean }>\n} {\n  return {\n    description: 'Agent Skills 目录结构 (遵循官方规范)',\n    structure: {\n      [SKILL_FILE_NAME]: {\n        description: 'Skill 定义文件 (必填)',\n        required: true,\n      },\n      [SCRIPTS_DIR_NAME + '/']: {\n        description: '可执行脚本目录 (可选)',\n        required: false,\n      },\n      [REFERENCES_DIR_NAME + '/']: {\n        description: '参考文档目录 (可选)',\n        required: false,\n      },\n      [ASSETS_DIR_NAME + '/']: {\n        description: '静态资源目录 (可选)',\n        required: false,\n      },\n    },\n  }\n}\n\n/**\n * 格式化 Skill 列表为可读格式\n */\nexport function formatSkillList(skills: Array<{ id: string; name: string; description: string }>): string {\n  if (skills.length === 0) {\n    return '没有可用的 Skills'\n  }\n\n  const lines: string[] = [`可用的 Skills (${skills.length} 个):`, '']\n\n  for (const skill of skills) {\n    lines.push(`- ${skill.name}`)\n    lines.push(`  ${skill.description}`)\n    lines.push('')\n  }\n\n  return lines.join('\\n')\n}\n\n/**\n * 验证 Skill 目录结构\n * 检查目录是否符合官方规范\n */\nexport function validateSkillDirectoryStructure(files: string[]): {\n  valid: boolean\n  hasSkillFile: boolean\n  hasScriptsDir: boolean\n  hasReferencesDir: boolean\n  hasAssetsDir: boolean\n  warnings: string[]\n} {\n  const warnings: string[] = []\n\n  // 检查必填文件\n  const hasSkillFile = files.some(f => f.endsWith(SKILL_FILE_NAME))\n\n  // 检查官方规范目录\n  const hasScriptsDir = files.some(f => f.includes(`${SCRIPTS_DIR_NAME}/`))\n  const hasReferencesDir = files.some(f => f.includes(`${REFERENCES_DIR_NAME}/`))\n  const hasAssetsDir = files.some(f => f.includes(`${ASSETS_DIR_NAME}/`))\n\n  // 检查旧格式文件 (向后兼容)\n  const hasOldReferenceFile = files.some(f => f.endsWith('/REFERENCE.md'))\n  const hasOldExamplesFile = files.some(f => f.endsWith('/EXAMPLES.md'))\n  const hasOldKeywordsFile = files.some(f => f.endsWith('/KEYWORDS.md'))\n\n  if (hasOldReferenceFile) {\n    warnings.push(\n      '检测到旧格式的 REFERENCE.md 文件，建议将其移动到 references/ 目录'\n    )\n  }\n\n  if (hasOldExamplesFile) {\n    warnings.push(\n      '检测到旧格式的 EXAMPLES.md 文件，建议将其移动到 references/ 目录'\n    )\n  }\n\n  if (hasOldKeywordsFile) {\n    warnings.push(\n      '检测到旧格式的 KEYWORDS.md 文件，建议将其内容合并到 SKILL.md 或移动到 references/ 目录'\n    )\n  }\n\n  return {\n    valid: hasSkillFile,\n    hasSkillFile,\n    hasScriptsDir,\n    hasReferencesDir,\n    hasAssetsDir,\n    warnings,\n  }\n}\n\n/**\n * 将旧格式 Skill 结构迁移到新格式 (官方规范)\n * 提供迁移建议和步骤\n */\nexport function getMigrationGuide(): {\n  title: string\n  description: string\n  steps: Array<{ from: string; to: string; description: string }>\n} {\n  return {\n    title: 'Skill 目录结构迁移指南',\n    description: '将旧格式的 Skill 迁移到符合官方规范的新格式',\n    steps: [\n      {\n        from: 'REFERENCE.md',\n        to: 'references/REFERENCE.md',\n        description: '将参考文档移动到 references/ 目录',\n      },\n      {\n        from: 'EXAMPLES.md',\n        to: 'references/EXAMPLES.md',\n        description: '将示例文档移动到 references/ 目录',\n      },\n      {\n        from: 'KEYWORDS.md',\n        to: 'SKILL.md 或 references/KEYWORDS.md',\n        description: '将关键词内容合并到 SKILL.md 或移动到 references/ 目录',\n      },\n      {\n        from: '无脚本目录',\n        to: 'scripts/',\n        description: '创建 scripts/ 目录存放可执行脚本',\n      },\n      {\n        from: '无资源目录',\n        to: 'assets/',\n        description: '创建 assets/ 目录存放模板、图片等静态资源',\n      },\n    ],\n  }\n}\n\n/**\n * 获取 Skill 模板 (用于创建新 Skill)\n */\nexport function getSkillTemplate(skillName: string, description: string): string {\n  return `---\nname: ${skillName}\ndescription: ${description}\n---\n# ${skillName}\n\nAdd your skill instructions here.\n\n## When to use\n\nUse this skill when...\n\n## Instructions\n\n1. First step\n2. Second step\n3. etc.\n\n## Notes\n\nAdd any additional notes here.\n`\n}\n"
  },
  {
    "path": "src/lib/skills/validator.ts",
    "content": "/**\n * Skill 验证器\n *\n * 验证 Skill 元数据和内容的完整性和正确性。\n * 遵循 Agent Skills 官方规范: https://agentskills.io/specification\n */\n\nimport {\n  SkillContent,\n  SkillYamlMetadata,\n  ValidationResult,\n  ValidationError,\n  ValidationWarning,\n} from './types'\nimport {\n  isValidSkillId,\n  isValidSkillName,\n  isValidSkillDescription,\n} from './parser'\n\n// ============================================================================\n// 验证函数\n// ============================================================================\n\n/**\n * 验证 Skill YAML 元数据 (符合官方规范)\n *\n * @param metadata - YAML 元数据\n * @returns 验证结果\n */\nexport function validateSkillYamlMetadata(metadata: SkillYamlMetadata): ValidationResult {\n  const errors: ValidationError[] = []\n  const warnings: ValidationWarning[] = []\n\n  // 验证必填字段 - name\n  if (!metadata.name || metadata.name.trim().length === 0) {\n    errors.push({\n      field: 'name',\n      message: 'name 字段不能为空',\n      severity: 'error',\n    })\n  } else if (!isValidSkillName(metadata.name)) {\n    errors.push({\n      field: 'name',\n      message: 'name 必须是 1-64 字符，只能包含小写字母、数字和连字符，不能以连字符开头或结尾，不能包含连续连字符',\n      severity: 'error',\n    })\n  }\n\n  // 验证必填字段 - description\n  if (!metadata.description || metadata.description.trim().length === 0) {\n    errors.push({\n      field: 'description',\n      message: 'description 字段不能为空',\n      severity: 'error',\n    })\n  } else if (!isValidSkillDescription(metadata.description)) {\n    errors.push({\n      field: 'description',\n      message: 'description 必须是 1-1024 字符',\n      severity: 'error',\n    })\n  }\n\n  // 验证可选字段 - license\n  if (metadata.license) {\n    if (metadata.license.length > 200) {\n      warnings.push({\n        field: 'license',\n        message: 'license 建议不超过 200 个字符',\n        severity: 'warning',\n      })\n    }\n  }\n\n  // 验证可选字段 - compatibility\n  if (metadata.compatibility) {\n    if (metadata.compatibility.length > 500) {\n      errors.push({\n        field: 'compatibility',\n        message: 'compatibility 不能超过 500 个字符',\n        severity: 'error',\n      })\n    }\n  }\n\n  // 验证 metadata 字段\n  if (metadata.metadata) {\n    for (const [key, value] of Object.entries(metadata.metadata)) {\n      if (key.length > 50) {\n        warnings.push({\n          field: 'metadata',\n          message: `metadata 键名 \"${key}\" 过长，建议不超过 50 个字符`,\n          severity: 'warning',\n        })\n      }\n      if (value.length > 500) {\n        warnings.push({\n          field: 'metadata',\n          message: `metadata \"${key}\" 的值过长，建议不超过 500 个字符`,\n          severity: 'warning',\n        })\n      }\n    }\n  }\n\n  // 验证 allowedTools (官方规范使用空格分隔)\n  if (metadata.allowedTools) {\n    const tools = Array.isArray(metadata.allowedTools)\n      ? metadata.allowedTools\n      : typeof metadata.allowedTools === 'string'\n        ? metadata.allowedTools.split(/\\s+/).filter(v => v.length > 0)\n        : []\n\n    if (tools.length === 0) {\n      warnings.push({\n        field: 'allowedTools',\n        message: 'allowedTools 为空，建议移除此字段或添加工具',\n        severity: 'warning',\n      })\n    }\n\n    // 验证工具名称格式\n    const invalidTools = tools.filter((tool) => !isValidToolName(tool))\n    if (invalidTools.length > 0) {\n      errors.push({\n        field: 'allowedTools',\n        message: `无效的工具名称: ${invalidTools.join(', ')}`,\n        severity: 'error',\n      })\n    }\n  }\n\n  // 验证扩展字段 (向后兼容)\n  if (metadata.version && !isValidVersion(metadata.version)) {\n    warnings.push({\n      field: 'version',\n      message: 'version 格式无效，应为 semver 格式 (如: 1.0.0)',\n      severity: 'warning',\n    })\n  }\n\n  return {\n    valid: errors.length === 0,\n    errors,\n    warnings,\n  }\n}\n\n/**\n * 验证 Skill 完整内容\n *\n * @param skill - Skill 内容\n * @returns 验证结果\n */\nexport function validateSkillContent(skill: SkillContent): ValidationResult {\n  const errors: ValidationError[] = []\n  const warnings: ValidationWarning[] = []\n\n  // 验证元数据\n  const metadataResult = validateSkillYamlMetadata({\n    name: skill.metadata.name,\n    description: skill.metadata.description,\n    license: skill.metadata.license,\n    compatibility: skill.metadata.compatibility,\n    metadata: skill.metadata.metadata,\n    allowedTools: skill.metadata.allowedTools,\n    version: skill.metadata.version,\n    author: skill.metadata.author,\n    model: skill.metadata.model,\n    userInvocable: skill.metadata.userInvocable,\n  })\n  errors.push(...metadataResult.errors)\n  warnings.push(...metadataResult.warnings)\n\n  // 验证 ID 格式\n  if (!isValidSkillId(skill.metadata.id)) {\n    errors.push({\n      field: 'id',\n      message: 'Skill ID 格式无效，必须与目录名匹配，且符合 name 字段格式要求',\n      severity: 'error',\n    })\n  }\n\n  // 验证 ID 与 name 匹配 (官方规范要求)\n  if (skill.metadata.id !== skill.metadata.name) {\n    warnings.push({\n      field: 'id',\n      message: 'Skill ID 应与 name 字段保持一致 (官方规范建议)',\n      severity: 'warning',\n    })\n  }\n\n  // 验证指令内容\n  if (!skill.instructions || skill.instructions.trim().length === 0) {\n    errors.push({\n      field: 'instructions',\n      message: '指令内容不能为空',\n      severity: 'error',\n    })\n  } else {\n    // 官方规范建议指令长度\n    if (skill.instructions.length > 10000) {\n      warnings.push({\n        field: 'instructions',\n        message: '指令内容超过 10000 字符，建议将详细文档移到 references/ 目录',\n        severity: 'warning',\n      })\n    }\n\n    if (skill.instructions.length < 50) {\n      warnings.push({\n        field: 'instructions',\n        message: '指令内容过短，建议提供更详细的说明',\n        severity: 'warning',\n      })\n    }\n  }\n\n  return {\n    valid: errors.length === 0,\n    errors,\n    warnings,\n  }\n}\n\n/**\n * 验证 Skill ID\n *\n * @param id - Skill ID\n * @returns 是否有效\n */\nexport function validateSkillId(id: string): boolean {\n  return isValidSkillId(id)\n}\n\n// ============================================================================\n// 辅助验证函数\n// ============================================================================\n\n/**\n * 验证版本号格式 (semver)\n *\n * @param version - 版本号字符串\n * @returns 是否有效\n */\nfunction isValidVersion(version: string): boolean {\n  return /^\\d+\\.\\d+\\.\\d+(?:-[a-zA-Z0-9.-]+)?(?:\\+[a-zA-Z0-9.-]+)?$/.test(version)\n}\n\n/**\n * 验证工具名称格式\n *\n * @param toolName - 工具名称\n * @returns 是否有效\n */\nfunction isValidToolName(toolName: string): boolean {\n  // 工具名称可以包含字母、数字、下划线、冒号和星号\n  // 例如: Bash, Read, git:*, jq:*\n  return /^[a-zA-Z_][a-zA-Z0-9_:*]*$/.test(toolName)\n}\n\n// ============================================================================\n// 工具函数\n// ============================================================================\n\n/**\n * 格式化验证结果为可读文本\n *\n * @param result - 验证结果\n * @returns 格式化的错误和警告信息\n */\nexport function formatValidationResult(result: ValidationResult): string {\n  const lines: string[] = []\n\n  if (result.valid) {\n    lines.push('✓ 验证通过')\n  } else {\n    lines.push('✗ 验证失败')\n  }\n\n  if (result.errors.length > 0) {\n    lines.push('\\n错误:')\n    for (const error of result.errors) {\n      lines.push(`  - ${error.field}: ${error.message}`)\n    }\n  }\n\n  if (result.warnings.length > 0) {\n    lines.push('\\n警告:')\n    for (const warning of result.warnings) {\n      lines.push(`  - ${warning.field}: ${warning.message}`)\n    }\n  }\n\n  return lines.join('\\n')\n}\n\n/**\n * 获取验证错误的摘要\n *\n * @param result - 验证结果\n * @returns 错误摘要\n */\nexport function getValidationSummary(result: ValidationResult): string {\n  if (result.valid) {\n    return '验证通过'\n  }\n\n  const errorCount = result.errors.length\n  const warningCount = result.warnings.length\n\n  const parts: string[] = []\n  if (errorCount > 0) {\n    parts.push(`${errorCount} 个错误`)\n  }\n  if (warningCount > 0) {\n    parts.push(`${warningCount} 个警告`)\n  }\n\n  return `验证失败: ${parts.join(', ')}`\n}\n"
  },
  {
    "path": "src/lib/speech/capabilities.ts",
    "content": "import type { SpeechCapabilities, SpeechCapabilityInput } from './types.ts'\n\nexport function getSpeechCapabilities({ audioModel, sttModel }: SpeechCapabilityInput): SpeechCapabilities {\n  const currentWindow = typeof window === 'undefined' ? undefined : window\n\n  return {\n    localTtsAvailable: Boolean(currentWindow?.speechSynthesis),\n    localSttAvailable: Boolean(currentWindow?.SpeechRecognition || currentWindow?.webkitSpeechRecognition),\n    modelTtsAvailable: Boolean(audioModel),\n    modelSttAvailable: Boolean(sttModel),\n  }\n}\n"
  },
  {
    "path": "src/lib/speech/preferences.ts",
    "content": "import type { SpeechMode } from './types.ts'\n\nexport function normalizeSpeechMode(value: unknown): SpeechMode {\n  if (value === 'local' || value === 'model' || value === 'auto') {\n    return value\n  }\n\n  return 'auto'\n}\n"
  },
  {
    "path": "src/lib/speech/resolver.ts",
    "content": "import type { SpeechCapabilities, SpeechEngineResolution, SpeechMode, SpeechTask } from './types.ts'\n\nfunction getAvailability(task: SpeechTask, capabilities: SpeechCapabilities) {\n  if (task === 'tts') {\n    return {\n      local: capabilities.localTtsAvailable,\n      model: capabilities.modelTtsAvailable,\n    }\n  }\n\n  return {\n    local: capabilities.localSttAvailable,\n    model: capabilities.modelSttAvailable,\n  }\n}\n\nexport function resolveSpeechEngine(\n  task: SpeechTask,\n  mode: SpeechMode,\n  capabilities: SpeechCapabilities,\n): SpeechEngineResolution {\n  const availability = getAvailability(task, capabilities)\n\n  if (mode === 'local') {\n    return availability.local\n      ? { available: true, engine: 'local', reason: 'local-preferred' }\n      : { available: false, engine: 'local', reason: 'local-unavailable' }\n  }\n\n  if (mode === 'model') {\n    return availability.model\n      ? { available: true, engine: 'model', reason: 'model-fallback' }\n      : { available: false, engine: 'model', reason: 'model-unavailable' }\n  }\n\n  if (availability.local) {\n    return { available: true, engine: 'local', reason: 'local-preferred' }\n  }\n\n  if (availability.model) {\n    return { available: true, engine: 'model', reason: 'model-fallback' }\n  }\n\n  return { available: false, engine: 'local', reason: 'local-unavailable' }\n}\n"
  },
  {
    "path": "src/lib/speech/runtime.ts",
    "content": "import { getSpeechCapabilities } from './capabilities.ts'\nimport { resolveSpeechEngine } from './resolver.ts'\nimport type { SpeechCapabilities, SpeechCapabilityInput, SpeechEngineResolution, SpeechTask } from './types.ts'\n\nexport interface SpeechPreferenceInput extends SpeechCapabilityInput {\n  textToSpeechMode: 'auto' | 'local' | 'model'\n  speechToTextMode: 'auto' | 'local' | 'model'\n}\n\nexport function resolvePreferredSpeechEngine(\n  task: SpeechTask,\n  settings: SpeechPreferenceInput,\n  capabilities?: SpeechCapabilities,\n): SpeechEngineResolution {\n  const resolvedCapabilities = capabilities ?? getSpeechCapabilities(settings)\n  const mode = task === 'tts' ? settings.textToSpeechMode : settings.speechToTextMode\n\n  return resolveSpeechEngine(task, mode, resolvedCapabilities)\n}\n\nexport function shouldFallbackToModelAfterLocalFailure(settings: SpeechPreferenceInput): boolean {\n  return settings.speechToTextMode === 'auto' && Boolean(settings.sttModel)\n}\n"
  },
  {
    "path": "src/lib/speech/transcription-fallback.ts",
    "content": "export const NO_TRANSCRIPTION_MESSAGE = 'No transcription. Configure a speech recognition model.'\n\nexport function getTranscriptionFallbackMessage(sttModel: string): string {\n  return sttModel ? '' : NO_TRANSCRIPTION_MESSAGE\n}\n"
  },
  {
    "path": "src/lib/speech/types.ts",
    "content": "export type SpeechTask = 'tts' | 'stt'\n\nexport type SpeechMode = 'auto' | 'local' | 'model'\n\nexport type SpeechEngine = 'local' | 'model'\n\nexport interface SpeechCapabilities {\n  localTtsAvailable: boolean\n  localSttAvailable: boolean\n  modelTtsAvailable: boolean\n  modelSttAvailable: boolean\n}\n\nexport interface SpeechCapabilityInput {\n  audioModel: string\n  sttModel: string\n}\n\nexport interface SpeechEngineResolution {\n  available: boolean\n  engine: SpeechEngine\n  reason: 'local-preferred' | 'model-fallback' | 'local-unavailable' | 'model-unavailable'\n}\n"
  },
  {
    "path": "src/lib/sync/auto-sync.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { decodeBase64ToString, getFiles as getGithubFiles, getFileCommits as getGithubFileCommits } from '@/lib/sync/github'\nimport { getFiles as getGiteeFiles, getFileCommits as getGiteeFileCommits } from '@/lib/sync/gitee'\nimport { getFileContent as getGitlabFileContent, getFileCommits as getGitlabFileCommits } from '@/lib/sync/gitlab'\nimport { getFileContent as getGiteaFileContent, getFileCommits as getGiteaFileCommits, getGiteaApiBaseUrl } from '@/lib/sync/gitea'\nimport { s3HeadObject, s3Download } from './s3'\nimport { webdavHeadObject, webdavDownload } from './webdav'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { toast } from '@/hooks/use-toast'\nimport { readTextFile, writeTextFile, stat, mkdir, exists } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\nimport {\n  checkFileLock,\n  detectAndHandleConflict,\n  mergeSimpleContent,\n  updateFileSyncTime,\n  cleanupExpiredLocks,\n  getFileSyncStatus,\n  getFileRestoreTime\n} from './conflict-resolution'\nimport { sanitizeFilePath, hasInvalidFileNameChars } from './filename-utils'\nimport { useSyncConfirmStore } from '@/stores/sync-confirm'\nimport useSyncStore from '@/stores/sync'\nimport emitter from '@/lib/emitter'\n\n// Store 实例缓存\nlet storeInstance: Store | null = null\n\n/**\n * 获取 Store 实例\n */\nasync function getStore(): Promise<Store> {\n  if (!storeInstance) {\n    storeInstance = await Store.load('store.json')\n  }\n  return storeInstance\n}\n\n/**\n * 获取 GitLab 分支配置\n */\nasync function getGitlabBranch(): Promise<string> {\n  const store = await getStore()\n  return await store.get<string>('gitlabBranch') || 'main'\n}\n\n/**\n * 获取 Gitea 分支配置\n */\nasync function getGiteaBranch(): Promise<string> {\n  const store = await getStore()\n  return await store.get<string>('giteaBranch') || 'main'\n}\n\n/**\n * 从 store 获取本地记录的远程 SHA\n */\nexport async function getLocalRecordedSha(filePath: string): Promise<string | null> {\n  const store = await getStore()\n  const syncedShas = await store.get<Record<string, string>>('syncedFileShas') || {}\n  return syncedShas[filePath] || null\n}\n\n/**\n * 设置本地记录的远程 SHA\n */\nexport async function setLocalRecordedSha(filePath: string, sha: string): Promise<void> {\n  const store = await getStore()\n  const syncedShas = await store.get<Record<string, string>>('syncedFileShas') || {}\n  syncedShas[filePath] = sha\n  await store.set('syncedFileShas', syncedShas)\n}\n\nexport interface FileMetadata {\n  path: string\n  localSha?: string\n  remoteSha?: string\n  lastModified?: number\n  lastSyncTime?: number\n  syncStatus: 'synced' | 'local_newer' | 'remote_newer' | 'conflict' | 'unknown'\n}\n\nexport interface SyncResult {\n  shouldUpdate: boolean\n  action: 'none' | 'pull' | 'push' | 'conflict'\n  localContent?: string\n  remoteContent?: string\n  reason?: string\n}\n\n/**\n * 计算文件内容的 SHA 值\n */\nexport async function calculateFileSha(content: string): Promise<string> {\n  const encoder = new TextEncoder()\n  const data = encoder.encode(content)\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\n  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\n}\n\n/**\n * 获取本地文件元数据（增强版，处理文件名兼容性和目录检查）\n */\nexport async function getLocalFileMetadata(path: string): Promise<FileMetadata> {\n  const workspace = await getWorkspacePath()\n  \n  // 检查并清理文件名\n  if (hasInvalidFileNameChars(path)) {\n    path = sanitizeFilePath(path)\n  }\n  \n  const pathOptions = await getFilePathOptions(path)\n  \n  try {\n    let fileStat\n    if (workspace.isCustom) {\n      fileStat = await stat(pathOptions.path)\n    } else {\n      fileStat = await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n    }\n\n    let content = ''\n    if (workspace.isCustom) {\n      content = await readTextFile(pathOptions.path)\n    } else {\n      content = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n    }\n\n    return {\n      path,\n      localSha: await calculateFileSha(content),\n      lastModified: fileStat.mtime?.getTime(),\n      syncStatus: 'unknown'\n    }\n  } catch (error) {\n    // 如果是目录不存在的错误，这是正常的，返回未知状态\n    if (error instanceof Error && \n        (error.message.includes('no such file') || \n         error.message.includes('not found') ||\n         error.message.includes('系统找不到指定的路径'))) {\n      return {\n        path,\n        syncStatus: 'unknown'\n      }\n    }\n    \n    return {\n      path,\n      syncStatus: 'unknown'\n    }\n  }\n}\n\n/**\n * 获取远程文件信息\n */\nexport async function getRemoteFileInfo(path: string): Promise<{ sha?: string; lastModified?: number }> {\n  const store = await Store.load('store.json')\n  const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github'\n\n  try {\n    let file\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        file = await getGithubFiles({ path, repo: githubRepo })\n        if (file) {\n          // 获取最新提交信息\n          const commits = await getGithubFileCommits({ path, repo: githubRepo })\n          if (commits && commits.length > 0) {\n            return {\n              sha: file.sha,\n              lastModified: new Date(commits[0].commit.committer.date).getTime()\n            }\n          }\n          // 当前平台 API 不直接返回 SHA，返回 undefined\n          return { sha: undefined }\n        }\n        break\n\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        file = await getGiteeFiles({ path, repo: giteeRepo })\n        if (file) {\n          const commits = await getGiteeFileCommits({ path, repo: giteeRepo })\n          if (commits && commits.length > 0) {\n            return {\n              sha: file.sha,\n              lastModified: new Date(commits[0].commit.committer.date).getTime()\n            }\n          }\n          // 当前平台 API 不直接返回 SHA，返回 undefined\n          return { sha: undefined }\n        }\n        break\n\n      case 'gitlab': {\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        const gitlabBranch = await getGitlabBranch()\n        file = await getGitlabFileContent({ path, ref: gitlabBranch, repo: gitlabRepo })\n        if (file) {\n          const commits = await getGitlabFileCommits({ path, repo: gitlabRepo })\n          if (commits && commits.data && commits.data.length > 0) {\n            return {\n              sha: commits.data[0].id,\n              lastModified: new Date(commits.data[0].committed_date).getTime()\n            }\n          }\n          // 当前平台 API 不直接返回 SHA，返回 undefined\n          return { sha: undefined }\n        }\n        break\n      }\n\n      case 'gitea': {\n        const giteaRepo = await getSyncRepoName('gitea')\n        const giteaBranch = await getGiteaBranch()\n        file = await getGiteaFileContent({ path, ref: giteaBranch, repo: giteaRepo })\n        if (file) {\n          const commits = await getGiteaFileCommits({ path, repo: giteaRepo })\n          if (commits && commits.data && commits.data.length > 0) {\n            return {\n              sha: commits.data[0].sha,\n              lastModified: new Date(commits.data[0].commit.committer.date).getTime()\n            }\n          }\n          // 当前平台 API 不直接返回 SHA，返回 undefined\n          return { sha: undefined }\n        }\n        break\n      }\n    }\n  } catch {\n    // 静默处理错误\n  }\n\n  return { sha: undefined, lastModified: undefined }\n}\n\n/**\n * 比较本地和远程文件版本\n * 注意：由于本地使用 SHA-256 而远程使用 Git blob SHA（SHA-1），两种算法不同\n * 因此不直接比较 SHA，而是依赖修改时间进行比较\n */\nexport async function compareFileVersions(path: string): Promise<SyncResult> {\n  // 检查当前平台是否是 S3\n  const store = await getStore()\n  const platform = await store.get<string>('primaryBackupMethod')\n\n  if (platform === 's3') {\n    return compareS3FileVersions(path)\n  }\n\n  if (platform === 'webdav') {\n    return compareWebDAVFileVersions(path)\n  }\n\n  const localMeta = await getLocalFileMetadata(path)\n  const remoteInfo = await getRemoteFileInfo(path)\n\n  // 获取最后同步时间和恢复时间\n  const syncStatus = await getFileSyncStatus(path)\n  const lastSyncTime = syncStatus.lastSyncTime\n  const lastRestoreTime = await getFileRestoreTime(path)\n\n  // SHA 比较逻辑：使用本地记录的远程 SHA 与当前远程 SHA 进行比较\n  if (remoteInfo.sha) {\n    const localRecordedSha = await getLocalRecordedSha(path)\n\n    // 如果有本地记录的 SHA 和远程 SHA，进行比较\n    if (localRecordedSha && localRecordedSha !== remoteInfo.sha) {\n      // SHA 不一致，说明远程文件已更新，需要拉取\n      return {\n        shouldUpdate: true,\n        action: 'pull',\n        reason: '远程文件已更新（SHA 不匹配），需要拉取更新'\n      }\n    }\n\n    // 如果没有本地记录的 SHA，但远程有内容，记录 SHA\n    if (!localRecordedSha) {\n      await setLocalRecordedSha(path, remoteInfo.sha)\n    } else {\n      // SHA 匹配，直接返回，无需继续比较时间\n      return {\n        shouldUpdate: false,\n        action: 'none',\n        reason: 'SHA 匹配，文件已同步'\n      }\n    }\n  }\n\n  // 如果本地文件不存在\n  if (!localMeta.localSha) {\n    if (remoteInfo.sha) {\n      return {\n        shouldUpdate: true,\n        action: 'pull',\n        reason: '本地文件不存在，需要从远程拉取'\n      }\n    }\n    return { shouldUpdate: false, action: 'none' }\n  }\n\n  // 如果远程文件不存在，但本地文件存在\n  if (!remoteInfo.sha) {\n    if (localMeta.localSha) {\n      return {\n        shouldUpdate: true,\n        action: 'push',\n        reason: '远程文件不存在，需要推送到远程'\n      }\n    }\n    return { shouldUpdate: false, action: 'none' }\n  }\n\n  // 比较修改时间（不直接比较 SHA，因为算法不同）\n  const localTime = localMeta.lastModified || 0\n  const remoteTime = remoteInfo.lastModified || 0\n\n  // 如果两个时间都未知，且两边都有内容，返回冲突（需要用户判断）\n  if (localTime === 0 && remoteTime === 0) {\n    return {\n      shouldUpdate: true,\n      action: 'conflict',\n      reason: '无法确定文件更新时间，需要手动处理'\n    }\n  }\n\n  // 如果远程时间未知（获取失败），但远程 SHA 存在\n  if (remoteTime === 0 && remoteInfo.sha) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '无法确定远程文件更新时间，拉取远程版本'\n    }\n  }\n\n  // 如果本地时间未知（获取失败），但本地 SHA 存在\n  if (localTime === 0 && localMeta.localSha) {\n    return {\n      shouldUpdate: true,\n      action: 'push',\n      reason: '无法确定本地文件更新时间，推送本地版本'\n    }\n  }\n\n  // 拉取后缓冲期（10秒）：如果本地时间 > 远程时间，但本地时间 ≈ 最后同步时间\n  // 说明这是刚拉取的内容，不是用户编辑的，不需要推送\n  const PULL_GRACE_PERIOD = 10 * 1000 // 10 秒\n  if (localTime > remoteTime) {\n    // 检查是否在同步或恢复缓冲期内\n    const isInSyncGrace = lastSyncTime && localTime - lastSyncTime < PULL_GRACE_PERIOD\n    const isInRestoreGrace = lastRestoreTime && localTime - lastRestoreTime < PULL_GRACE_PERIOD\n    if (isInSyncGrace || isInRestoreGrace) {\n      return {\n        shouldUpdate: false,\n        action: 'none',\n        reason: '刚完成同步或恢复，处于缓冲期内，不触发推送'\n      }\n    }\n  }\n\n  if (remoteTime > localTime) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '远程文件较新，需要拉取更新'\n    }\n  } else if (localTime > remoteTime) {\n    return {\n      shouldUpdate: true,\n      action: 'push',\n      reason: '本地文件较新，需要推送更新'\n    }\n  }\n\n  // 如果时间相同，认为已同步（避免频繁冲突）\n  return {\n    shouldUpdate: false,\n    action: 'none',\n    reason: '文件修改时间相同，认为已同步'\n  }\n}\n\n/**\n * 从远程拉取文件内容\n */\nexport async function pullRemoteFile(path: string): Promise<string> {\n  const store = await Store.load('store.json')\n  const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github'\n\n  try {\n    let file\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        file = await getGithubFiles({ path, repo: githubRepo })\n        if (file && typeof file.content === 'string') {\n          return decodeBase64ToString(file.content)\n        }\n        break\n\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        file = await getGiteeFiles({ path, repo: giteeRepo })\n        if (file && typeof file.content === 'string') {\n          return decodeBase64ToString(file.content)\n        }\n        break\n\n      case 'gitlab': {\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        const gitlabBranch = await getGitlabBranch()\n        file = await getGitlabFileContent({ path, ref: gitlabBranch, repo: gitlabRepo })\n        if (file && typeof file.content === 'string') {\n          return decodeBase64ToString(file.content)\n        }\n        break\n      }\n\n      case 'gitea': {\n        const giteaRepo = await getSyncRepoName('gitea')\n        const giteaBranch = await getGiteaBranch()\n        file = await getGiteaFileContent({ path, ref: giteaBranch, repo: giteaRepo })\n        if (file && typeof file.content === 'string') {\n          return decodeBase64ToString(file.content)\n        }\n        break\n      }\n\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3File = await s3Download(s3Config, path)\n          if (s3File) {\n            return s3File.content\n          }\n        }\n        break\n      }\n\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavFile = await webdavDownload(webdavConfig, path)\n          if (webdavFile) {\n            return webdavFile.content\n          }\n        }\n        break\n      }\n    }\n  } catch (error) {\n    throw error\n  }\n\n  throw new Error('无法获取远程文件内容')\n}\n\n/**\n * 确保目录存在，如果不存在则创建\n */\nexport async function ensureDirectoryExists(filePath: string): Promise<void> {\n  const workspace = await getWorkspacePath()\n  \n  // 检查并清理文件名\n  if (hasInvalidFileNameChars(filePath)) {\n    filePath = sanitizeFilePath(filePath)\n  }\n  \n  // 提取目录路径\n  const dirPath = filePath.includes('/') ? filePath.split('/').slice(0, -1).join('/') : ''\n  \n  if (!dirPath) {\n    return // 根目录，无需创建\n  }\n  \n  const pathOptions = await getFilePathOptions(dirPath)\n  \n  try {\n    let dirExists = false\n    if (workspace.isCustom) {\n      dirExists = await exists(pathOptions.path)\n    } else {\n      dirExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n    }\n    \n    if (!dirExists) {\n      // 递归创建目录\n      if (workspace.isCustom) {\n        await mkdir(pathOptions.path, { recursive: true })\n      } else {\n        await mkdir(pathOptions.path, { baseDir: pathOptions.baseDir, recursive: true })\n      }\n    }\n  } catch (error) {\n    throw error\n  }\n}\n\n/**\n * 保存文件到本地（增强版，处理文件名兼容性和目录创建）\n */\nexport async function saveLocalFile(path: string, content: string): Promise<void> {\n  const workspace = await getWorkspacePath()\n  \n  // 检查并清理文件名\n  if (hasInvalidFileNameChars(path)) {\n    path = sanitizeFilePath(path)\n  }\n  \n  // 确保目录存在\n  await ensureDirectoryExists(path)\n  \n  const pathOptions = await getFilePathOptions(path)\n  \n  try {\n    if (workspace.isCustom) {\n      await writeTextFile(pathOptions.path, content)\n    } else {\n      await writeTextFile(pathOptions.path, content, { baseDir: pathOptions.baseDir })\n    }\n  } catch (error) {\n    throw error\n  }\n}\n\n/**\n * 获取远程文件的最新 commit 信息\n */\nexport async function getRemoteCommitInfo(path: string): Promise<{\n  sha: string\n  message: string\n  author: string\n  date: Date\n  additions?: number\n  deletions?: number\n} | null> {\n  try {\n    const store = await Store.load('store.json')\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github'\n    const repo = await getSyncRepoName(primaryBackupMethod as 'github' | 'gitee' | 'gitlab' | 'gitea')\n    \n    let commits: any[] = []\n    \n    switch (primaryBackupMethod) {\n      case 'github':\n        commits = await getGithubFileCommits({ path, repo })\n        break\n      case 'gitee':\n        commits = await getGiteeFileCommits({ path, repo })\n        break\n      case 'gitlab':\n        const gitlabResult = await getGitlabFileCommits({ path, repo })\n        commits = Array.isArray(gitlabResult) ? gitlabResult : []\n        break\n      case 'gitea':\n        const giteaResult = await getGiteaFileCommits({ path, repo })\n        commits = Array.isArray(giteaResult) ? giteaResult : []\n        break\n    }\n    \n    if (!commits || commits.length === 0) {\n      return null\n    }\n    \n    const latestCommit = commits[0]\n    \n    // 提取 commit 信息\n    let author = 'Unknown'\n    let message = 'No message'\n    let date = new Date()\n    let sha = ''\n    let additions: number | undefined\n    let deletions: number | undefined\n    \n    if (primaryBackupMethod === 'github') {\n      author = latestCommit.commit?.author?.name || 'Unknown'\n      message = latestCommit.commit?.message || 'No message'\n      date = new Date(latestCommit.commit?.author?.date || Date.now())\n      sha = latestCommit.sha || ''\n      additions = latestCommit.stats?.additions\n      deletions = latestCommit.stats?.deletions\n    } else if (primaryBackupMethod === 'gitee') {\n      author = latestCommit.author?.name || 'Unknown'\n      message = latestCommit.message || 'No message'\n      date = new Date(latestCommit.created_at || Date.now())\n      sha = latestCommit.sha || ''\n    } else if (primaryBackupMethod === 'gitlab') {\n      author = latestCommit.author_name || 'Unknown'\n      message = latestCommit.message || 'No message'\n      date = new Date(latestCommit.created_at || Date.now())\n      sha = latestCommit.id || ''\n    } else if (primaryBackupMethod === 'gitea') {\n      author = latestCommit.commit?.author?.name || 'Unknown'\n      message = latestCommit.commit?.message || 'No message'\n      date = new Date(latestCommit.commit?.author?.date || Date.now())\n      sha = latestCommit.sha || ''\n    }\n    \n    return {\n      sha,\n      message,\n      author,\n      date,\n      additions,\n      deletions\n    }\n  } catch {\n    return null\n  }\n}\n\n/**\n * 自动同步检测和处理（增强版，包含冲突处理和 commit 信息展示）\n */\nexport async function autoSyncIfNeeded(path: string, options: {\n  autoPull?: boolean\n  showConfirm?: boolean\n  enableConflictResolution?: boolean\n} = {}): Promise<string | null> {\n  const { autoPull = true, showConfirm = false, enableConflictResolution = true } = options\n  \n  try {\n    // 清理过期锁\n    await cleanupExpiredLocks()\n    \n    // 检查文件是否被其他设备锁定\n    if (enableConflictResolution) {\n      const lockInfo = await checkFileLock(path)\n      if (lockInfo) {\n        toast({\n          title: '文件锁定',\n          description: `文件正在被 ${lockInfo.userName} 在其他设备上编辑`,\n          variant: 'destructive'\n        })\n        return null\n      }\n    }\n    \n    const syncResult = await compareFileVersions(path)\n    \n    if (!syncResult.shouldUpdate || syncResult.action === 'none') {\n      return null\n    }\n    \n    if (syncResult.action === 'pull' && autoPull) {\n      if (showConfirm) {\n        // 获取 commit 信息\n        const commitInfo = await getRemoteCommitInfo(path)\n\n        // 使用新的拉取确认对话框\n        return new Promise<string | null>((resolve) => {\n          useSyncConfirmStore.getState().showPullDialog({\n            fileName: path || '',\n            commitInfo: commitInfo || undefined,\n            onConfirm: async () => {\n              try {\n                // 执行实际的同步逻辑\n                const result = await performSync(path || '', enableConflictResolution)\n                resolve(result)\n              } catch {\n                resolve(null)\n              }\n            },\n            onCancel: () => {\n              resolve(null)\n            }\n          })\n        })\n      } else {\n        // 直接执行同步（不显示确认对话框）\n        return await performSync(path, enableConflictResolution)\n      }\n    }\n    \n    return null\n  } catch {\n    return null\n  }\n}\n\n/**\n * 执行实际的同步操作\n */\nasync function performSync(path: string, enableConflictResolution: boolean): Promise<string | null> {\n  try {\n    // 获取本地内容用于冲突检测\n    let localContent = ''\n    let actualPath = path\n    \n    // 检查并清理文件名\n    if (hasInvalidFileNameChars(path)) {\n      actualPath = sanitizeFilePath(path)\n    }\n    \n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(actualPath)\n      if (workspace.isCustom) {\n        localContent = await readTextFile(pathOptions.path)\n      } else {\n        localContent = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch (error) {\n      // 本地文件不存在或目录不存在，这是正常的同步场景\n      if (error instanceof Error && \n          (error.message.includes('no such file') || \n           error.message.includes('not found') ||\n           error.message.includes('系统找不到指定的路径'))) {\n      } else {\n        // 静默处理读取本地文件时的意外错误\n      }\n      // 继续处理，将直接拉取远程文件\n    }\n    \n    const remoteContent = await pullRemoteFile(path)\n\n    // 获取远程文件的 SHA，用于后续更新记录的 SHA\n    const remoteInfo = await getRemoteFileInfo(path)\n    const remoteSha = remoteInfo.sha\n\n    // 检测和处理冲突\n    if (enableConflictResolution && localContent && localContent !== remoteContent) {\n      const resolution = await detectAndHandleConflict(path, localContent, remoteContent)\n      \n      let finalContent = remoteContent\n      switch (resolution.action) {\n        case 'keep_local':\n          finalContent = localContent\n          toast({\n            title: '冲突处理',\n            description: '保留本地版本'\n          })\n          break\n        case 'keep_remote':\n          finalContent = remoteContent\n          toast({\n            title: '冲突处理',\n            description: '使用远程版本'\n          })\n          break\n        case 'merge':\n          finalContent = mergeSimpleContent(localContent, remoteContent)\n          toast({\n            title: '冲突处理',\n            description: '自动合并成功'\n          })\n          break\n        case 'manual':\n          toast({\n            title: '需要手动处理',\n            description: '冲突较复杂，请手动处理',\n            variant: 'destructive'\n          })\n          return null\n      }\n      \n      await saveLocalFile(actualPath, finalContent)\n      await updateFileSyncTime(actualPath)\n\n      // 成功拉取后，更新记录的 SHA\n      if (remoteSha) {\n        await setLocalRecordedSha(actualPath, remoteSha)\n      }\n\n      // 通知编辑器内容已更新\n      emitter.emit('sync-content-updated', { path: actualPath, content: finalContent })\n\n      return finalContent\n    } else {\n      // 无冲突，直接保存\n      await saveLocalFile(actualPath, remoteContent)\n      await updateFileSyncTime(actualPath)\n\n      // 成功拉取后，更新记录的 SHA\n      if (remoteSha) {\n        await setLocalRecordedSha(actualPath, remoteSha)\n      }\n\n      // 通知编辑器内容已更新\n      emitter.emit('sync-content-updated', { path: actualPath, content: remoteContent })\n\n      return remoteContent\n    }\n  } catch {\n    return null\n  }\n  \n  return null\n}\n\n/**\n * 检查网络连接状态\n */\nexport async function hasNetworkConnection(): Promise<boolean> {\n  try {\n    const store = await Store.load('store.json')\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github'\n\n    // 真正的网络检测：尝试发送请求到 API 端点\n    const controller = new AbortController()\n    const timeoutId = setTimeout(() => controller.abort(), 10000) // 10秒超时\n\n    let url = ''\n    let token = ''\n    let proxy: Proxy | undefined = undefined\n\n    switch (primaryBackupMethod) {\n      case 'github':\n        token = await store.get<string>('accessToken') || ''\n        url = 'https://api.github.com/user'\n        break\n      case 'gitee':\n        token = await store.get<string>('giteeAccessToken') || ''\n        url = 'https://gitee.com/api/v5/user'\n        break\n      case 'gitlab':\n        token = await store.get<string>('gitlabAccessToken') || ''\n        const gitlabUrl = await store.get<string>('gitlabUrl') || 'https://gitlab.com'\n        url = `${gitlabUrl}/api/v4/user`\n        break\n      case 'gitea':\n        token = await store.get<string>('giteaAccessToken') || ''\n        url = `${await getGiteaApiBaseUrl()}/user`\n        // Gitea 自建实例可能需要代理\n        const giteaProxyUrl = await store.get<string>('proxy')\n        if (giteaProxyUrl) {\n          proxy = { all: giteaProxyUrl }\n        }\n        break\n      default:\n        clearTimeout(timeoutId)\n        return false\n    }\n\n    if (!token) {\n      clearTimeout(timeoutId)\n      return false\n    }\n\n    const fetchOptions: any = {\n      method: 'GET',\n      signal: controller.signal,\n      headers: {\n        'Authorization': `Bearer ${token}`\n      }\n    }\n\n    // Gitea 自建实例使用代理\n    if (proxy) {\n      fetchOptions.proxy = proxy\n    }\n\n    const response = await fetch(url, fetchOptions)\n\n    clearTimeout(timeoutId)\n    return response.ok\n  } catch (error) {\n    // 网络错误、超时等\n    console.error('Network connection check failed:', error)\n    return false\n  }\n}\n\n/**\n * 比较 S3 本地和远程文件版本\n * 使用 ETag 进行比较\n */\nexport async function compareS3FileVersions(path: string): Promise<SyncResult> {\n  // 获取 S3 配置\n  const store = await getStore()\n  const config = await store.get<S3Config>('s3SyncConfig')\n  if (!config) {\n    return { shouldUpdate: false, action: 'none', reason: 'S3 未配置' }\n  }\n\n  // 获取 proxy\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy = proxyUrl ? { all: proxyUrl } : undefined\n\n  // 获取本地文件的元数据\n  const localMeta = await getLocalFileMetadata(path)\n\n  // 从 sync store 获取本地记录的云端 ETag\n  const syncStoreState = useSyncStore.getState()\n  const localRecordedEtag = syncStoreState.s3FileEtags[path]\n\n  // 获取远程文件的 ETag\n  const remoteInfo = await s3HeadObject(config, path, proxy)\n\n  // 如果远程不存在\n  if (!remoteInfo) {\n    if (localMeta.localSha) {\n      return {\n        shouldUpdate: true,\n        action: 'push',\n        reason: '远程文件不存在，需要推送到远程'\n      }\n    }\n    return { shouldUpdate: false, action: 'none' }\n  }\n\n  // 如果本地不存在\n  if (!localMeta.localSha) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '本地文件不存在，需要从远程拉取'\n    }\n  }\n\n  // 比较 ETag\n  if (localRecordedEtag && localRecordedEtag !== remoteInfo.etag) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '远程文件已更新（ETag 不匹配），需要拉取更新'\n    }\n  }\n\n  // ETag 匹配\n  if (localRecordedEtag === remoteInfo.etag) {\n    return {\n      shouldUpdate: false,\n      action: 'none',\n      reason: 'ETag 匹配，文件已同步'\n    }\n  }\n\n  // 没有本地记录的 ETag，记录并检查时间\n  // 使用修改时间比较\n  const localTime = localMeta.lastModified || 0\n  const remoteTime = remoteInfo.lastModified ? new Date(remoteInfo.lastModified).getTime() : 0\n\n  if (localTime > remoteTime) {\n    return {\n      shouldUpdate: true,\n      action: 'push',\n      reason: '本地文件较新，需要推送'\n    }\n  }\n\n  return {\n    shouldUpdate: true,\n    action: 'pull',\n    reason: '远程文件较新，需要拉取'\n  }\n}\n\n/**\n * 比较 WebDAV 本地和远程文件版本\n * 使用 ETag 进行比较\n */\nexport async function compareWebDAVFileVersions(path: string): Promise<SyncResult> {\n  // 获取 WebDAV 配置\n  const store = await getStore()\n  const config = await store.get<WebDAVConfig>('webdavSyncConfig')\n  if (!config) {\n    return { shouldUpdate: false, action: 'none', reason: 'WebDAV 未配置' }\n  }\n\n  // 获取 proxy\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy = proxyUrl ? { all: proxyUrl } : undefined\n\n  // 获取本地文件的元数据\n  const localMeta = await getLocalFileMetadata(path)\n\n  // 从 sync store 获取本地记录的云端 ETag\n  const syncStoreState = useSyncStore.getState()\n  const localRecordedEtag = syncStoreState.webdavFileEtags[path]\n\n  // 获取远程文件的 ETag\n  const remoteInfo = await webdavHeadObject(config, path, proxy)\n\n  // 如果远程不存在\n  if (!remoteInfo) {\n    if (localMeta.localSha) {\n      return {\n        shouldUpdate: true,\n        action: 'push',\n        reason: '远程文件不存在，需要推送到远程'\n      }\n    }\n    return { shouldUpdate: false, action: 'none' }\n  }\n\n  // 如果本地不存在\n  if (!localMeta.localSha) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '本地文件不存在，需要从远程拉取'\n    }\n  }\n\n  // 比较 ETag\n  if (localRecordedEtag && localRecordedEtag !== remoteInfo.etag) {\n    return {\n      shouldUpdate: true,\n      action: 'pull',\n      reason: '远程文件已更新（ETag 不匹配），需要拉取更新'\n    }\n  }\n\n  // ETag 匹配\n  if (localRecordedEtag === remoteInfo.etag) {\n    return {\n      shouldUpdate: false,\n      action: 'none',\n      reason: 'ETag 匹配，文件已同步'\n    }\n  }\n\n  // 没有本地记录的 ETag，记录并检查时间\n  // 使用修改时间比较\n  const localTime = localMeta.lastModified || 0\n  const remoteTime = remoteInfo.lastModified ? new Date(remoteInfo.lastModified).getTime() : 0\n\n  if (localTime > remoteTime) {\n    return {\n      shouldUpdate: true,\n      action: 'push',\n      reason: '本地文件较新，需要推送'\n    }\n  }\n\n  return {\n    shouldUpdate: true,\n    action: 'pull',\n    reason: '远程文件较新，需要拉取'\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/conflict-resolution.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { confirm } from '@tauri-apps/plugin-dialog'\n\n/**\n * 冲突解决策略类型\n */\nexport type ConflictResolutionStrategy = 'local' | 'remote' | 'manual'\n\nexport interface ConflictResolution {\n  action: 'keep_local' | 'keep_remote' | 'merge' | 'manual'\n  reason?: string\n}\n\nexport interface SyncLock {\n  filePath: string\n  deviceId: string\n  timestamp: number\n  userName: string\n}\n\n/**\n * 获取设备唯一标识\n */\nexport async function getDeviceId(): Promise<string> {\n  const store = await Store.load('store.json')\n  let deviceId = await store.get<string>('deviceId')\n  \n  if (!deviceId) {\n    // 生成设备唯一标识\n    deviceId = `device_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`\n    await store.set('deviceId', deviceId)\n    await store.save()\n  }\n  \n  return deviceId\n}\n\n/**\n * 获取用户名\n */\nexport async function getUserName(): Promise<string> {\n  const store = await Store.load('store.json')\n  return await store.get<string>('username') || 'Unknown User'\n}\n\n/**\n * 检查文件是否被其他设备锁定\n */\nexport async function checkFileLock(filePath: string): Promise<SyncLock | null> {\n  const store = await Store.load('store.json')\n  const locks = await store.get<Record<string, SyncLock>>('fileLocks') || {}\n  \n  const lock = locks[filePath]\n  if (!lock) {\n    return null\n  }\n  \n  // 检查锁是否过期（5分钟）\n  const now = Date.now()\n  if (now - lock.timestamp > 5 * 60 * 1000) {\n    // 锁已过期，清除\n    delete locks[filePath]\n    await store.set('fileLocks', locks)\n    await store.save()\n    return null\n  }\n  \n  // 如果是当前设备的锁，忽略\n  const currentDeviceId = await getDeviceId()\n  if (lock.deviceId === currentDeviceId) {\n    return null\n  }\n  \n  return lock\n}\n\n/**\n * 获取文件锁\n */\nexport async function acquireFileLock(filePath: string): Promise<boolean> {\n  const store = await Store.load('store.json')\n  const locks = await store.get<Record<string, SyncLock>>('fileLocks') || {}\n  \n  // 检查是否已被其他设备锁定\n  const existingLock = locks[filePath]\n  if (existingLock) {\n    const currentDeviceId = await getDeviceId()\n    if (existingLock.deviceId !== currentDeviceId) {\n      // 检查锁是否过期\n      const now = Date.now()\n      if (now - existingLock.timestamp <= 5 * 60 * 1000) {\n        return false // 锁仍然有效\n      }\n    }\n  }\n  \n  // 获取锁\n  const deviceId = await getDeviceId()\n  const userName = await getUserName()\n  \n  locks[filePath] = {\n    filePath,\n    deviceId,\n    timestamp: Date.now(),\n    userName\n  }\n  \n  await store.set('fileLocks', locks)\n  await store.save()\n  \n  return true\n}\n\n/**\n * 释放文件锁\n */\nexport async function releaseFileLock(filePath: string): Promise<void> {\n  const store = await Store.load('store.json')\n  const locks = await store.get<Record<string, SyncLock>>('fileLocks') || {}\n  \n  const currentDeviceId = await getDeviceId()\n  const lock = locks[filePath]\n  \n  if (lock && lock.deviceId === currentDeviceId) {\n    delete locks[filePath]\n    await store.set('fileLocks', locks)\n    await store.save()\n  }\n}\n\n/**\n * 检测和处理冲突\n * @param filePath 文件路径\n * @param localContent 本地内容\n * @param remoteContent 远程内容\n * @param strategy 可选的冲突解决策略，如果提供则直接使用该策略\n */\nexport async function detectAndHandleConflict(\n  filePath: string,\n  localContent: string,\n  remoteContent: string,\n  strategy?: ConflictResolutionStrategy\n): Promise<ConflictResolution> {\n  // 如果内容相同，不是冲突\n  if (localContent === remoteContent) {\n    return { action: 'keep_local', reason: '内容相同，无需处理' }\n  }\n\n  // 如果提供了策略，直接使用策略解决\n  if (strategy) {\n    const result = await resolveConflict(filePath, localContent, remoteContent, strategy)\n    if (result.resolved) {\n      return {\n        action: strategy === 'local' ? 'keep_local' : strategy === 'remote' ? 'keep_remote' : 'manual',\n        reason: `使用${strategy}策略解决冲突`\n      }\n    } else {\n      return { action: 'manual', reason: '需要用户手动处理' }\n    }\n  }\n\n  // 分析冲突类型\n  const conflictType = analyzeConflictType(localContent, remoteContent)\n\n  switch (conflictType) {\n    case 'simple_addition':\n      // 简单的内容追加，可以自动合并\n      return { action: 'merge', reason: '检测到简单的内容追加，可以自动合并' }\n\n    case 'significant_change':\n      // 显著内容变化，需要用户选择\n      return await promptUserForResolution(filePath, localContent, remoteContent)\n\n    case 'format_only':\n      // 仅格式变化，保留远程版本\n      return { action: 'keep_remote', reason: '检测到格式变化，使用远程版本' }\n\n    default:\n      return await promptUserForResolution(filePath, localContent, remoteContent)\n  }\n}\n\n/**\n * 分析冲突类型\n */\nfunction analyzeConflictType(localContent: string, remoteContent: string): 'simple_addition' | 'significant_change' | 'format_only' {\n  const localLines = localContent.split('\\n')\n  const remoteLines = remoteContent.split('\\n')\n\n  // 检查是否只是简单的追加\n  if (localLines.length < remoteLines.length) {\n    const localPrefix = remoteLines.slice(0, localLines.length).join('\\n')\n    if (localContent === localPrefix) {\n      return 'simple_addition'\n    }\n  }\n\n  // 检查是否只是格式变化（去除空白字符后内容相同）\n  const normalizedLocal = localContent.replace(/\\s+/g, ' ').trim()\n  const normalizedRemote = remoteContent.replace(/\\s+/g, ' ').trim()\n\n  if (normalizedLocal === normalizedRemote) {\n    return 'format_only'\n  }\n\n  return 'significant_change'\n}\n\n/**\n * 导出分析冲突类型函数供外部使用\n */\nexport function analyzeConflictTypeExported(localContent: string, remoteContent: string): 'simple_addition' | 'significant_change' | 'format_only' {\n  return analyzeConflictType(localContent, remoteContent)\n}\n\n/**\n * 根据策略解决冲突\n * @param filePath 文件路径\n * @param localContent 本地内容\n * @param remoteContent 远程内容\n * @param strategy 冲突解决策略\n * @returns 解决后的内容和是否已解决\n */\nexport async function resolveConflict(\n  filePath: string,\n  localContent: string,\n  remoteContent: string,\n  strategy: ConflictResolutionStrategy\n): Promise<{ content: string; resolved: boolean }> {\n  switch (strategy) {\n    case 'local':\n      return { content: localContent, resolved: true }\n    case 'remote':\n      return { content: remoteContent, resolved: true }\n    case 'manual':\n      // 返回特殊标记，表示需要用户手动处理\n      return { content: localContent, resolved: false }\n  }\n}\n\n/**\n * 提示用户选择冲突解决方案\n */\nasync function promptUserForResolution(\n  filePath: string,\n  localContent: string,\n  remoteContent: string\n): Promise<ConflictResolution> {\n  const choice = await confirm(\n    `文件 ${filePath} 存在冲突\\n\\n` +\n    `本地版本：${localContent.length} 字符\\n` +\n    `远程版本：${remoteContent.length} 字符\\n\\n` +\n    `请选择如何处理：\\n` +\n    `• 确定：保留本地版本\\n` +\n    `• 取消：保留远程版本`,\n    { \n      title: '同步冲突',\n      okLabel: '保留本地',\n      cancelLabel: '保留远程'\n    }\n  )\n  \n  return {\n    action: choice ? 'keep_local' : 'keep_remote',\n    reason: choice ? '用户选择保留本地版本' : '用户选择保留远程版本'\n  }\n}\n\n/**\n * 智能合并简单冲突\n */\nexport function mergeSimpleContent(localContent: string, remoteContent: string): string {\n  const localLines = localContent.split('\\n')\n  const remoteLines = remoteContent.split('\\n')\n  \n  // 如果远程内容包含本地内容，直接返回远程内容\n  if (remoteLines.length >= localLines.length) {\n    const localPrefix = remoteLines.slice(0, localLines.length).join('\\n')\n    if (localContent === localPrefix) {\n      return remoteContent\n    }\n  }\n  \n  // 如果本地内容包含远程内容，返回本地内容\n  if (localLines.length >= remoteLines.length) {\n    const remotePrefix = localLines.slice(0, remoteLines.length).join('\\n')\n    if (remoteContent === remotePrefix) {\n      return localContent\n    }\n  }\n  \n  // 尝试行级别的合并\n  const mergedLines = [...localLines]\n  for (const line of remoteLines) {\n    if (!localLines.includes(line)) {\n      mergedLines.push(line)\n    }\n  }\n  \n  return mergedLines.join('\\n')\n}\n\n/**\n * 定期清理过期的文件锁\n */\nexport async function cleanupExpiredLocks(): Promise<void> {\n  const store = await Store.load('store.json')\n  const locks = await store.get<Record<string, SyncLock>>('fileLocks') || {}\n  \n  const now = Date.now()\n  const expiredKeys: string[] = []\n  \n  for (const [filePath, lock] of Object.entries(locks)) {\n    if (now - lock.timestamp > 5 * 60 * 1000) { // 5分钟过期\n      expiredKeys.push(filePath)\n    }\n  }\n  \n  if (expiredKeys.length > 0) {\n    for (const key of expiredKeys) {\n      delete locks[key]\n    }\n    await store.set('fileLocks', locks)\n    await store.save()\n  }\n}\n\n/**\n * 获取文件的同步状态\n */\nexport async function getFileSyncStatus(filePath: string): Promise<{\n  isLocked: boolean\n  lockInfo?: SyncLock\n  lastSyncTime?: number\n}> {\n  const store = await Store.load('store.json')\n  \n  // 检查锁状态\n  const lockInfo = await checkFileLock(filePath)\n  \n  // 获取最后同步时间\n  const syncTimes = await store.get<Record<string, number>>('lastSyncTimes') || {}\n  const lastSyncTime = syncTimes[filePath]\n  \n  return {\n    isLocked: !!lockInfo,\n    lockInfo: lockInfo || undefined,\n    lastSyncTime\n  }\n}\n\n/**\n * 更新文件的同步时间\n */\nexport async function updateFileSyncTime(filePath: string): Promise<void> {\n  const store = await Store.load('store.json')\n  const syncTimes = await store.get<Record<string, number>>('lastSyncTimes') || {}\n\n  syncTimes[filePath] = Date.now()\n  await store.set('lastSyncTimes', syncTimes)\n  await store.save()\n}\n\n/**\n * 获取文件的恢复时间\n */\nexport async function getFileRestoreTime(filePath: string): Promise<number | undefined> {\n  const store = await Store.load('store.json')\n  const restoreTimes = await store.get<Record<string, number>>('lastRestoreTimes') || {}\n  return restoreTimes[filePath]\n}\n\n/**\n * 更新文件的恢复时间\n */\nexport async function updateFileRestoreTime(filePath: string): Promise<void> {\n  const store = await Store.load('store.json')\n  const restoreTimes = await store.get<Record<string, number>>('lastRestoreTimes') || {}\n\n  restoreTimes[filePath] = Date.now()\n  await store.set('lastRestoreTimes', restoreTimes)\n  await store.save()\n}\n"
  },
  {
    "path": "src/lib/sync/encode-fetch.ts",
    "content": "import { invoke } from '@tauri-apps/api/core';\nimport { ClientOptions } from '@tauri-apps/plugin-http';\n\nconst ERROR_REQUEST_CANCELLED = 'Request canceled';\n\nasync function fetch(input: string, init?: RequestInit & ClientOptions) {\n  // abort early here if needed\n  const signal = init?.signal;\n  if (signal?.aborted) {\n      throw new Error(ERROR_REQUEST_CANCELLED);\n  }\n  const maxRedirections = init?.maxRedirections;\n  const connectTimeout = init?.connectTimeout;\n  const proxy = init?.proxy;\n  const danger = init?.danger;\n  // Remove these fields before creating the request\n  if (init) {\n      delete init.maxRedirections;\n      delete init.connectTimeout;\n      delete init.proxy;\n      delete init.danger;\n  }\n  const headers = init?.headers\n      ? init.headers instanceof Headers\n          ? init.headers\n          : new Headers(init.headers)\n      : new Headers();\n  const req = new Request(input, init);\n  const buffer = await req.arrayBuffer();\n  const data = buffer.byteLength !== 0 ? Array.from(new Uint8Array(buffer)) : null;\n\n  for (const [key, value] of req.headers) {\n    if (!headers.get(key)) {\n      headers.set(key, value);\n    }\n  }\n  const headersArray = headers instanceof Headers\n    ? Array.from(headers.entries())\n    : Array.isArray(headers)\n      ? headers\n      : Object.entries(headers);\n  const mappedHeaders = headersArray.map(([name, val]) => [\n    name,\n    typeof val === 'string' ? val : (val as string).toString()\n  ]);\n  if (signal?.aborted) {\n      throw new Error(ERROR_REQUEST_CANCELLED);\n  }\n  const rid = await invoke('plugin:http|fetch', {\n    clientConfig: {\n      method: req.method,\n      url: req.url,\n      headers: mappedHeaders,\n      data,\n      maxRedirections,\n      connectTimeout,\n      proxy,\n      danger\n    }\n  });\n  const abort = () => invoke('plugin:http|fetch_cancel', { rid });\n  if (signal?.aborted) {\n    abort();\n    throw new Error(ERROR_REQUEST_CANCELLED);\n  }\n  signal?.addEventListener('abort', () => void abort());\n  const { status, statusText, url, headers: responseHeaders, rid: responseRid } = await invoke<{\n    status: number;\n    statusText: string;\n    url: string;\n    headers: [string, string][];\n    rid: number;\n  }>('plugin:http|fetch_send', {\n    rid\n  });\n  const body = await invoke('plugin:http|fetch_read_body', {\n    rid: responseRid\n  });\n  const res = new Response(body instanceof ArrayBuffer && body.byteLength !== 0\n    ? body\n    : body instanceof Array && body.length > 0\n        ? new Uint8Array(body)\n        : null, {\n    status,\n    statusText\n  });\n\n  Object.defineProperty(res, 'url', { value: url });\n\n  const encodeHeaders = responseHeaders.map(header => [header[0], encodeURI(header[1])]) as [string, string][]\n\n  Object.defineProperty(res, 'headers', {\n    value: new Headers(encodeHeaders)\n  });\n  return res;\n}\n\nexport { fetch };\n"
  },
  {
    "path": "src/lib/sync/filename-utils.ts",
    "content": "/**\n * 清理文件名，确保跨平台兼容性\n * Windows 不允许的字符: < > : \" | ? * \n * 同时处理其他可能的特殊字符\n */\nexport function sanitizeFileName(fileName: string): string {\n  // Windows 不允许的字符\n  const windowsInvalidChars = /[<>:\"|?*]/g\n  \n  // 替换不允许的字符为下划线\n  let sanitized = fileName.replace(windowsInvalidChars, '_')\n  \n  // 移除或替换其他可能有问题的字符\n  sanitized = sanitized\n    .replace(/\\r\\n/g, '_') // 换行符\n    .replace(/\\n/g, '_')    // 换行符\n    .replace(/\\r/g, '_')    // 回车符\n    .replace(/\\t/g, '_')    // 制表符\n    .replace(/\\0/g, '_')    // 空字符\n    .replace(/[\\u0000-\\u001F]/g, '_') // 控制字符\n    .trim() // 移除首尾空白\n  \n  // 确保文件名不以点开头（隐藏文件）\n  if (sanitized.startsWith('.')) {\n    sanitized = '_' + sanitized.slice(1)\n  }\n  \n  // 确保文件名不为空\n  if (!sanitized) {\n    sanitized = 'untitled'\n  }\n  \n  // 限制文件名长度（Windows 限制为 255 字符）\n  const maxLength = 250 // 留一些余量\n  if (sanitized.length > maxLength) {\n    const extension = sanitized.includes('.') ? sanitized.split('.').pop() : ''\n    const nameWithoutExt = sanitized.includes('.') ? \n      sanitized.slice(0, -(extension!.length + 1)) : sanitized\n    \n    const maxNameLength = maxLength - (extension ? extension.length + 1 : 0)\n    const truncatedName = nameWithoutExt.slice(0, maxNameLength)\n    \n    sanitized = extension ? `${truncatedName}.${extension}` : truncatedName\n  }\n  \n  return sanitized\n}\n\n/**\n * 清理完整路径中的所有文件名\n */\nexport function sanitizeFilePath(filePath: string): string {\n  // 分割路径\n  const parts = filePath.split('/')\n  \n  // 清理每个部分（除了可能的空字符串）\n  const sanitizedParts = parts.map(part => {\n    if (part === '') return part\n    return sanitizeFileName(part)\n  })\n  \n  return sanitizedParts.join('/')\n}\n\n/**\n * 检查文件名是否包含不允许的字符\n */\nexport function hasInvalidFileNameChars(fileName: string): boolean {\n  const windowsInvalidChars = /[<>:\"|?*]/\n  return windowsInvalidChars.test(fileName) ||\n         fileName.includes('\\r') ||\n         fileName.includes('\\n') ||\n         fileName.includes('\\t') ||\n         fileName.includes('\\0')\n}\n\n/**\n * 获取文件名的安全版本，如果原文件名安全则返回原文件名\n */\nexport function getSafeFileName(originalFileName: string): string {\n  if (!hasInvalidFileNameChars(originalFileName)) {\n    return originalFileName\n  }\n  \n  const safeFileName = sanitizeFileName(originalFileName)\n\n  return safeFileName\n}\n"
  },
  {
    "path": "src/lib/sync/folder-sync-helper.ts",
    "content": "import { FolderSync, FolderSyncResult } from './folder-sync'\nimport { computedParentPath } from '@/lib/path'\nimport { DirTree } from '@/stores/article'\nimport { toast } from '@/hooks/use-toast'\n\nlet folderSyncInstance: FolderSync | null = null\n\nfunction getFolderSync(): FolderSync {\n  if (!folderSyncInstance) {\n    folderSyncInstance = new FolderSync()\n  }\n  return folderSyncInstance\n}\n\nexport async function syncFolderByItem(item: DirTree): Promise<FolderSyncResult> {\n  const folderPath = computedParentPath(item)\n  const sync = getFolderSync()\n  return await sync.syncFolder(folderPath)\n}\n\nexport function showFolderSyncToast(result: FolderSyncResult) {\n  if (result.success) {\n    toast({\n      title: '文件夹同步成功',\n      description: result.message\n    })\n  } else {\n    toast({\n      title: '文件夹同步失败',\n      description: result.message,\n      variant: 'destructive'\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/folder-sync.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { readTextFile } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\nimport { collectMarkdownFiles } from '@/lib/files'\nimport { RepoNames } from './github.types'\nimport { getGiteaApiBaseUrl } from './gitea'\nimport { s3Upload } from './s3'\nimport { webdavUpload } from './webdav'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\n\nexport interface FolderSyncResult {\n  success: boolean\n  totalFiles: number\n  successCount: number\n  failedCount: number\n  message: string\n  errors?: string[]\n}\n\nexport class FolderSync {\n  private platform: string = 'github'\n\n  constructor() {\n    // 不再在 constructor 中初始化\n  }\n\n  /**\n   * 初始化平台配置（在每次同步前调用以获取最新配置）\n   */\n  private async init() {\n    const store = await Store.load('store.json')\n    this.platform = await store.get<string>('primaryBackupMethod') || 'github'\n  }\n\n  async syncFolder(localFolderPath: string): Promise<FolderSyncResult> {\n    // 每次同步前重新读取平台配置\n    await this.init()\n\n    try {\n      // 1. 获取本地文件夹下所有 Markdown 文件\n      const markdownFiles = await collectMarkdownFiles(localFolderPath)\n\n      if (markdownFiles.length === 0) {\n        return {\n          success: false,\n          totalFiles: 0,\n          successCount: 0,\n          failedCount: 0,\n          message: '当前文件夹下没有 Markdown 文件'\n        }\n      }\n\n      // 2. 读取每个文件的内容\n      const workspace = await getWorkspacePath()\n      const filesToUpload: Array<{ path: string; content: string; sha?: string }> = []\n\n      for (const file of markdownFiles) {\n        const pathOptions = await getFilePathOptions(file.path)\n        let content = ''\n\n        if (workspace.isCustom) {\n          content = await readTextFile(pathOptions.path)\n        } else {\n          content = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        // 相对路径作为远程路径\n        const remotePath = file.path\n\n        filesToUpload.push({\n          path: remotePath,\n          content\n        })\n      }\n\n      // 3. 根据平台执行批量提交\n      const message = `Sync folder: ${localFolderPath} - ${new Date().toLocaleString('zh-CN')}`\n      let success = false\n\n      switch (this.platform) {\n        case 'github': {\n          // GitHub 批量提交\n          success = await this._githubBatchCommit(RepoNames.sync, filesToUpload, message)\n          break\n        }\n        case 'gitee': {\n          // 先获取远程文件 SHA（用于覆盖）\n          const giteeFiles = await this._getGiteeFiles(RepoNames.sync)\n          for (const file of filesToUpload) {\n            if (giteeFiles[file.path]) {\n              file.sha = giteeFiles[file.path].sha\n            }\n          }\n          // Gitee: 逐个上传，带 SHA 可以覆盖\n          success = await this._giteeBatchCommit(RepoNames.sync, filesToUpload, message)\n          break\n        }\n        case 'gitlab':\n          success = await this._gitlabBatchCommit(RepoNames.sync, filesToUpload, message)\n          break\n        case 'gitea':\n          success = await this._giteaBatchCommit(RepoNames.sync, filesToUpload)\n          break\n        case 's3':\n          success = await this._s3BatchUpload(filesToUpload)\n          break\n        case 'webdav':\n          success = await this._webdavBatchUpload(filesToUpload)\n          break\n        default:\n          return {\n            success: false,\n            totalFiles: markdownFiles.length,\n            successCount: 0,\n            failedCount: markdownFiles.length,\n            message: `不支持的平台: ${this.platform}`\n          }\n      }\n\n      if (success) {\n        return {\n          success: true,\n          totalFiles: markdownFiles.length,\n          successCount: markdownFiles.length,\n          failedCount: 0,\n          message: `成功同步 ${markdownFiles.length} 个文件`\n        }\n      } else {\n        return {\n          success: false,\n          totalFiles: markdownFiles.length,\n          successCount: 0,\n          failedCount: markdownFiles.length,\n          message: '同步失败'\n        }\n      }\n    } catch (error) {\n      return {\n        success: false,\n        totalFiles: 0,\n        successCount: 0,\n        failedCount: 0,\n        message: String(error),\n        errors: [String(error)]\n      }\n    }\n  }\n\n  /**\n   * 获取远程仓库中所有文件的 SHA\n   */\n  async _getGithubTreeFiles(\n    repo: string,\n    path: string\n  ): Promise<Record<string, { sha: string; type: string }>> {\n    const store = await Store.load('store.json')\n    const accessToken = await store.get<string>('accessToken')\n    const githubUsername = await store.get<string>('githubUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${accessToken}`)\n    headers.append('Accept', 'application/vnd.github+json')\n    headers.append('X-GitHub-Api-Version', '2022-11-28')\n\n    // 使用 git tree API 获取指定路径下的所有文件\n    const url = `https://api.github.com/repos/${githubUsername}/${repo}/git/trees/main?recursive=1`\n    const response = await fetch(url, { method: 'GET', headers, proxy })\n\n    if (!response.ok) return {}\n\n    const data = await response.json()\n    const result: Record<string, { sha: string; type: string }> = {}\n\n    if (data.tree) {\n      for (const item of data.tree) {\n        if (item.path && item.path.startsWith(path) && item.type === 'blob') {\n          result[item.path] = { sha: item.sha, type: item.type }\n        }\n      }\n    }\n\n    return result\n  }\n\n  /**\n   * 批量提交多个文件到 GitHub\n   */\n  async _githubBatchCommit(\n    repo: string,\n    files: Array<{ path: string; content: string; sha?: string }>,\n    message: string\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const accessToken = await store.get<string>('accessToken')\n    const githubUsername = await store.get<string>('githubUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    // 构建 tree\n    // 注意：GitHub API 不允许同时提供 sha 和 content\n    // 只提供 content，让 GitHub 自动处理（新文件创建 blob，已存在文件也会创建新的 blob）\n    const tree = files.map((file) => ({\n      path: file.path,\n      mode: '100644',\n      type: 'blob',\n      content: Buffer.from(file.content).toString('base64'),\n    }))\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${accessToken}`)\n    headers.append('Accept', 'application/vnd.github+json')\n    headers.append('X-GitHub-Api-Version', '2022-11-28')\n    headers.append('Content-Type', 'application/json')\n\n    // 1. 创建 tree\n    const createTreeUrl = `https://api.github.com/repos/${githubUsername}/${repo}/git/trees`\n    const treeResponse = await fetch(createTreeUrl, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({ tree }),\n      proxy,\n    })\n\n    if (!treeResponse.ok) {\n      console.error('创建 tree 失败:', await treeResponse.text())\n      return false\n    }\n\n    const treeData = await treeResponse.json()\n\n    // 2. 获取当前 commit SHA\n    const refUrl = `https://api.github.com/repos/${githubUsername}/${repo}/git/ref/heads/main`\n    const refResponse = await fetch(refUrl, { method: 'GET', headers, proxy })\n    if (!refResponse.ok) return false\n    const refData = await refResponse.json()\n    const parentCommitSha = refData.object.sha\n\n    // 3. 创建 commit\n    const commitUrl = `https://api.github.com/repos/${githubUsername}/${repo}/git/commits`\n    const commitResponse = await fetch(commitUrl, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({\n        message,\n        tree: treeData.sha,\n        parents: [parentCommitSha],\n      }),\n      proxy,\n    })\n\n    if (!commitResponse.ok) {\n      console.error('创建 commit 失败:', await commitResponse.text())\n      return false\n    }\n\n    const commitData = await commitResponse.json()\n\n    // 4. 更新 ref\n    const updateRefUrl = `https://api.github.com/repos/${githubUsername}/${repo}/git/refs/heads/main`\n    const updateResponse = await fetch(updateRefUrl, {\n      method: 'PATCH',\n      headers,\n      body: JSON.stringify({\n        sha: commitData.sha,\n        force: true,\n      }),\n      proxy,\n    })\n\n    return updateResponse.ok\n  }\n\n  /**\n   * 获取 Gitee 仓库中所有文件的 SHA（递归获取子目录）\n   */\n  async _getGiteeFiles(repo: string, path: string = ''): Promise<Record<string, { sha: string }>> {\n    const store = await Store.load('store.json')\n    const giteeAccessToken = await store.get<string>('giteeAccessToken')\n    const giteeUsername = await store.get<string>('giteeUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!giteeAccessToken || !giteeUsername) {\n      console.error('[Gitee] 缺少 accessToken 或 username')\n      return {}\n    }\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${giteeAccessToken}`)\n\n    // 使用 Gitee API 获取仓库内容\n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}/contents${path ? '/' + path : ''}?access_token=${giteeAccessToken}`\n    const response = await fetch(url, { method: 'GET', headers, proxy })\n\n    if (!response.ok) {\n      console.error('[Gitee] 获取文件列表失败:', await response.text())\n      return {}\n    }\n\n    const data = await response.json()\n    const result: Record<string, { sha: string }> = {}\n\n    if (Array.isArray(data)) {\n      for (const item of data) {\n        if (item.type === 'file' && item.path && item.sha) {\n          result[item.path] = { sha: item.sha }\n        } else if (item.type === 'dir' && item.path) {\n          // 递归获取子目录\n          const subFiles = await this._getGiteeFiles(repo, item.path)\n          Object.assign(result, subFiles)\n        }\n      }\n    }\n\n    return result\n  }\n\n  /**\n   * 获取 Gitea 仓库中所有文件的 SHA（递归获取子目录）\n   */\n  async _getGiteaFiles(repo: string, path: string = ''): Promise<Record<string, { sha: string }>> {\n    const store = await Store.load('store.json')\n    const giteaAccessToken = await store.get<string>('giteaAccessToken')\n    const giteaUsername = await store.get<string>('giteaUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!giteaAccessToken || !giteaUsername) {\n      console.error('[Gitea] 缺少 accessToken 或 username')\n      return {}\n    }\n\n    let giteaUrl: string\n    try {\n      giteaUrl = await getGiteaApiBaseUrl()\n    } catch {\n      return {}\n    }\n\n    const apiBaseUrl = giteaUrl.endsWith('/') ? giteaUrl.slice(0, -1) : giteaUrl\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${giteaAccessToken}`)\n\n    // 对路径进行编码处理，与 getFiles 保持一致\n    const encodedPath = path.replace(/\\s/g, '_').split('/').map(encodeURIComponent).join('/')\n    const url = `${apiBaseUrl}/repos/${giteaUsername}/${repo}/contents${encodedPath ? '/' + encodedPath : ''}`\n\n    try {\n      const response = await fetch(url, { method: 'GET', headers, proxy })\n\n      if (!response.ok) {\n        console.error('[Gitea] 获取文件列表失败:', response.status)\n        return {}\n      }\n\n      const data = await response.json()\n      const result: Record<string, { sha: string }> = {}\n\n      if (Array.isArray(data)) {\n        for (const item of data) {\n          if (item.type === 'file' && item.path && item.sha) {\n            result[item.path] = { sha: item.sha }\n          } else if (item.type === 'dir' && item.path) {\n            // 递归获取子目录\n            const subFiles = await this._getGiteaFiles(repo, item.path)\n            Object.assign(result, subFiles)\n          }\n        }\n      }\n\n      return result\n    } catch (error) {\n      console.error('[Gitea] 获取文件列表异常:', error)\n      return {}\n    }\n  }\n\n  /**\n   * Gitee 批量提交\n   * 注意：Gitee API 不支持真正的批量操作，这里使用并发上传\n   */\n  async _giteeBatchCommit(\n    repo: string,\n    files: Array<{ path: string; content: string; sha?: string }>,\n    message: string\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const giteeAccessToken = await store.get<string>('giteeAccessToken')\n    const giteeUsername = await store.get<string>('giteeUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!giteeAccessToken || !giteeUsername) {\n      console.error('[Gitee] 缺少 accessToken 或 username')\n      return false\n    }\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${giteeAccessToken}`)\n    headers.append('Content-Type', 'application/json')\n\n    // Gitee API: 使用单个文件操作，每个文件一次请求\n    // 使用并发上传提高速度\n\n    const uploadPromises = files.map(async (file) => {\n      const base64Content = Buffer.from(file.content).toString('base64')\n      const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}/contents/${file.path}`\n\n      const body: Record<string, unknown> = {\n        access_token: giteeAccessToken,\n        content: base64Content,\n        message: message\n      }\n\n      // 如果有 SHA（文件已存在），使用 PUT 方法覆盖\n      if (file.sha) {\n        body.sha = file.sha\n      }\n\n      const response = await fetch(url, {\n        method: file.sha ? 'PUT' : 'POST',\n        headers,\n        body: JSON.stringify(body),\n        proxy\n      })\n\n      if (!response.ok) {\n        const errorText = await response.text()\n        console.error(`[Gitee] 上传文件 ${file.path} 失败:`, errorText)\n      }\n\n      return response.ok\n    })\n\n    const results = await Promise.all(uploadPromises)\n    const successCount = results.filter(r => r).length\n\n    // 只要有一个文件成功就算成功\n    return successCount > 0\n  }\n\n  /**\n   * GitLab 批量提交（使用 commit with actions）\n   */\n  async _gitlabBatchCommit(\n    repo: string,\n    files: Array<{ path: string; content: string; sha?: string }>,\n    message: string\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n    const gitlabUrl = await store.get<string>('gitlabUrl') || 'https://gitlab.com'\n    const gitlabBranch = await store.get<string>('gitlabBranch') || 'main'\n    const gitlabProjectId = await store.get<string>(`gitlab_${repo}_project_id`)\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!gitlabAccessToken) {\n      console.error('[GitLab] 缺少 accessToken')\n      return false\n    }\n\n    if (!gitlabProjectId) {\n      console.error('[GitLab] 缺少 projectId')\n      return false\n    }\n\n    const headers = new Headers()\n    headers.append('PRIVATE-TOKEN', gitlabAccessToken)\n    headers.append('Content-Type', 'application/json;charset=iso-8859-1')\n\n    // 构建 actions 数组\n    const actions = files.map(file => ({\n      action: file.sha ? 'update' : 'create',\n      file_path: file.path,\n      content: Buffer.from(file.content).toString('base64'),\n      ...(file.sha && { sha: file.sha })\n    }))\n\n    const url = `${gitlabUrl}/api/v4/projects/${encodeURIComponent(gitlabProjectId)}/repository/commits`\n\n    const response = await fetch(url, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({\n        branch: gitlabBranch,\n        commit_message: message,\n        actions\n      }),\n      proxy\n    })\n\n    if (!response.ok) {\n      const errorText = await response.text()\n      console.error('[GitLab] 批量提交失败:', errorText)\n      return false\n    }\n\n    return true\n  }\n\n  /**\n   * Gitea 批量提交（使用单个文件上传 + 并发）\n   * Gitea API 不支持批量 commit，需要逐个上传文件\n   */\n  async _giteaBatchCommit(\n    repo: string,\n    files: Array<{ path: string; content: string; sha?: string }>\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const giteaAccessToken = await store.get<string>('giteaAccessToken')\n    const giteaUsername = await store.get<string>('giteaUsername')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    let giteaUrl: string\n    try {\n      giteaUrl = await getGiteaApiBaseUrl()\n    } catch (error) {\n      console.error('[Gitea] 获取 API URL 失败:', error)\n      return false\n    }\n\n    if (!giteaAccessToken || !giteaUsername) {\n      console.error('[Gitea] 缺少配置: accessToken 或 username')\n      return false\n    }\n\n    const headers = new Headers()\n    headers.append('Authorization', `Bearer ${giteaAccessToken}`)\n    headers.append('Content-Type', 'application/json')\n\n    const apiBaseUrl = giteaUrl.endsWith('/') ? giteaUrl.slice(0, -1) : giteaUrl\n\n    // 先获取远程文件 SHA（用于覆盖）\n    const remoteFiles = await this._getGiteaFiles(repo)\n\n    // 为每个文件设置 SHA\n    for (const file of files) {\n      if (remoteFiles[file.path]) {\n        file.sha = remoteFiles[file.path].sha\n      }\n    }\n\n    // 使用顺序上传（避免并发导致分支锁定冲突）\n    let successCount = 0\n    const uploadedPaths = new Set<string>()\n\n    for (let i = 0; i < files.length; i++) {\n      const file = files[i]\n      const base64Content = Buffer.from(file.content).toString('base64')\n\n      // 分离路径和文件名\n      const lastSlashIndex = file.path.lastIndexOf('/')\n      const dirPath = lastSlashIndex > 0 ? file.path.substring(0, lastSlashIndex) : ''\n      const fileName = lastSlashIndex > 0 ? file.path.substring(lastSlashIndex + 1) : file.path\n\n      // 编码路径\n      const normalizedPath = dirPath\n        ? `${dirPath.split('/').map(p => encodeURIComponent(p.replace(/\\s/g, '_'))).join('/')}/${fileName.replace(/\\s/g, '_')}`\n        : fileName.replace(/\\s/g, '_')\n\n      const url = `${apiBaseUrl}/repos/${giteaUsername}/${repo}/contents/${normalizedPath}`\n\n      const requestBody: Record<string, unknown> = {\n        branch: 'main',\n        content: base64Content,\n        message: file.sha ? `Update ${fileName}` : `Create ${fileName}`\n      }\n\n      // 如果有 SHA，使用 PUT 覆盖\n      if (file.sha) {\n        requestBody.sha = file.sha\n      }\n\n      const response = await fetch(url, {\n        method: file.sha ? 'PUT' : 'POST',\n        headers,\n        body: JSON.stringify(requestBody),\n        proxy\n      })\n\n      if (!response.ok) {\n        const errorText = await response.text()\n        console.error(`[Gitea] 上传文件 ${file.path} 失败:`, response.status, errorText)\n        // 继续上传下一个文件\n        continue\n      }\n\n      successCount++\n      uploadedPaths.add(file.path)\n\n      // 重新获取剩余文件的 SHA（因为分支已更新）\n      if (i < files.length - 1) {\n        const newRemoteFiles = await this._getGiteaFiles(repo)\n        // 更新后续文件中尚未上传的文件的 SHA\n        for (let j = i + 1; j < files.length; j++) {\n          const otherFile = files[j]\n          if (!uploadedPaths.has(otherFile.path) && newRemoteFiles[otherFile.path]) {\n            otherFile.sha = newRemoteFiles[otherFile.path].sha\n          }\n        }\n      }\n    }\n\n    return successCount > 0\n  }\n\n  /**\n   * S3 批量上传\n   */\n  async _s3BatchUpload(\n    files: Array<{ path: string; content: string }>\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const s3Config = await store.get<S3Config>('s3SyncConfig')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!s3Config || !s3Config.accessKeyId || !s3Config.secretAccessKey || !s3Config.region || !s3Config.bucket) {\n      console.error('[S3] 缺少配置')\n      return false\n    }\n\n    // 使用并发上传\n    const uploadPromises = files.map(async (file) => {\n      const result = await s3Upload(s3Config, file.path, file.content, proxy)\n      if (!result) {\n        console.error(`[S3] 上传文件 ${file.path} 失败`)\n      }\n      return !!result\n    })\n\n    const results = await Promise.all(uploadPromises)\n    const successCount = results.filter(r => r).length\n\n    return successCount > 0\n  }\n\n  /**\n   * WebDAV 批量上传\n   */\n  async _webdavBatchUpload(\n    files: Array<{ path: string; content: string }>\n  ): Promise<boolean> {\n    const store = await Store.load('store.json')\n    const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n    const proxyUrl = await store.get<string>('proxy')\n    const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined\n\n    if (!webdavConfig || !webdavConfig.url || !webdavConfig.username || !webdavConfig.password) {\n      console.error('[WebDAV] 缺少配置')\n      return false\n    }\n\n    // 使用并发上传\n    const uploadPromises = files.map(async (file) => {\n      const result = await webdavUpload(webdavConfig, file.path, file.content, proxy)\n      if (!result) {\n        console.error(`[WebDAV] 上传文件 ${file.path} 失败`)\n      }\n      return !!result\n    })\n\n    const results = await Promise.all(uploadPromises)\n    const successCount = results.filter(r => r).length\n\n    return successCount > 0\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/gitea.ts",
    "content": "import { toast } from '@/hooks/use-toast';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { v4 as uuid } from 'uuid';\nimport { fetch, Proxy } from '@tauri-apps/plugin-http';\nimport { fetch as encodeFetch } from './encode-fetch'\nimport { \n  GiteaInstanceType, \n  GiteaRepositoryInfo, \n  GITEA_INSTANCES, \n  GiteaError,\n  GiteaUserInfo,\n  GiteaCommit,\n  GiteaResponse,\n  GiteaDirectoryItem,\n  GiteaFileContent\n} from './gitea.types';\n\n// 获取 Gitea 实例的 API 基础 URL\nexport async function getGiteaApiBaseUrl(): Promise<string> {\n  const store = await Store.load('store.json');\n  const instanceType = await store.get<GiteaInstanceType>('giteaInstanceType') || GiteaInstanceType.OFFICIAL;\n\n  if (instanceType === GiteaInstanceType.SELF_HOSTED) {\n    let customUrl = await store.get<string>('giteaCustomUrl') || '';\n    // 移除末尾的斜杠，避免双斜杠问题\n    customUrl = customUrl.replace(/\\/+$/, '').trim();\n\n    // 验证自定义 URL 是否有效\n    if (!customUrl) {\n      throw new Error('自建 Gitea 实例的 URL 未配置，请先在设置中填写 Gitea URL');\n    }\n\n    // 确保 URL 包含协议\n    if (!customUrl.startsWith('http://') && !customUrl.startsWith('https://')) {\n      customUrl = 'http://' + customUrl;\n    }\n\n    return `${customUrl}/api/v1`;\n  }\n\n  const instance = GITEA_INSTANCES[instanceType];\n  return `${instance.baseUrl}/api/v1`;\n}\n\n// 获取通用请求头\nasync function getCommonHeaders(): Promise<any> {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('giteaAccessToken');\n\n  if (!accessToken) {\n    throw new Error('Gitea Access Token 未配置');\n  }\n\n  const headers = {\n    \"Content-Type\": 'application/json;charset=utf-8',\n    \"Authorization\": `token ${accessToken}`,\n  };\n\n  return headers;\n}\n\n// 获取代理配置\nasync function getProxyConfig(): Promise<Proxy | undefined> {\n  const store = await Store.load('store.json');\n  const proxyUrl = await store.get<string>('proxy');\n  return proxyUrl ? { all: proxyUrl } : undefined;\n}\n\n/**\n * 上传文件到 Gitea 仓库\n * @param params 上传参数\n */\nexport async function uploadFile({\n  file,\n  filename,\n  sha,\n  message,\n  repo,\n  path\n}: {\n  file: string;\n  filename?: string;\n  sha?: string;\n  message?: string;\n  repo: string;\n  path?: string;\n}) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n\n    if (!giteaUsername) {\n      throw new Error('Gitea 用户名未配置');\n    }\n\n    const id = uuid();\n    // path 可能是完整路径（如 \"视频文案/03_免费的笔记同步方案.md\"）\n    // 也可能是目录路径（如 \"视频文案\"）\n    // filename 是文件名（如 \"03_免费的笔记同步方案.md\"）\n\n    // 从 path 中分离目录和文件名\n    let dirPath: string;\n    let _filename: string;\n\n    if (path) {\n      const lastSlashIndex = path.lastIndexOf('/');\n      if (lastSlashIndex > 0) {\n        // path 包含目录和文件名\n        dirPath = path.substring(0, lastSlashIndex);\n        _filename = filename || path.substring(lastSlashIndex + 1);\n      } else if (lastSlashIndex === -1 && path) {\n        // path 是纯目录名（如 .settings），filename 单独传\n        dirPath = path;\n        _filename = filename || id;\n      } else {\n        // path 为空\n        dirPath = '';\n        _filename = filename || id;\n      }\n    } else {\n      dirPath = '';\n      _filename = filename || id;\n    }\n\n    // 将空格转换成下划线\n    _filename = _filename.replace(/\\s/g, '_');\n    // 对文件名进行编码\n    const encodedFilename = encodeURIComponent(_filename);\n\n    // 组合完整路径\n    let normalizedPath: string;\n    if (dirPath) {\n      const encodedDir = dirPath.split('/').map(p => encodeURIComponent(p.replace(/\\s/g, '_'))).join('/');\n      normalizedPath = `${encodedDir}/${encodedFilename}`;\n    } else {\n      normalizedPath = encodedFilename;\n    }\n\n    // 将内容转换为 Base64（Gitea API 要求）\n    const base64Content = Buffer.from(file, 'utf-8').toString('base64')\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    const requestBody: any = {\n      branch: 'main',\n      content: base64Content,\n      message: message || `Upload ${filename || id}`,\n      // 设置提交时间为当前时间\n      dates: {\n        author: new Date().toISOString(),\n        committer: new Date().toISOString()\n      }\n    };\n\n    // 如果是更新文件，需要添加 sha\n    if (sha) {\n      requestBody.sha = sha;\n    }\n\n    const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${normalizedPath}`;\n    // Gitea API: POST 创建新文件，PUT 更新现有文件\n    const method = sha ? 'PUT' : 'POST';\n\n    const response = await fetch(url, {\n      method,\n      headers,\n      body: JSON.stringify(requestBody),\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return { data } as GiteaResponse<any>;\n    }\n\n    if (response.status === 400) {\n      return null;\n    }\n\n    // 422 表示文件已存在（需要 SHA 才能更新），返回 null 以便触发重试\n    if (response.status === 422) {\n      return null;\n    }\n\n    // 404 表示文件不存在，尝试用 POST 创建新文件\n    if (response.status === 404) {\n      const postMethod = 'POST';\n      const postBody = { ...requestBody };\n      delete postBody.sha; // POST 不需要 sha\n\n      const postResponse = await fetch(url, {\n        method: postMethod,\n        headers,\n        body: JSON.stringify(postBody),\n        proxy\n      });\n\n      if (postResponse.status >= 200 && postResponse.status < 300) {\n        const data = await postResponse.json();\n        return { data } as GiteaResponse<any>;\n      }\n\n      const postErrorData = await postResponse.json();\n      throw {\n        status: postResponse.status,\n        message: postErrorData.message || '同步失败'\n      } as GiteaError;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '同步失败'\n    } as GiteaError;\n\n  } catch (error) {\n    toast({\n      title: '同步失败',\n      description: (error as GiteaError).message || '上传文件时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 更新文件内容（获取文件 sha 后上传）\n * @param params 更新参数\n */\nexport async function updateFileContent({\n  path,\n  repo,\n  content,\n  message\n}: {\n  path: string;\n  repo: string;\n  content: string;\n  message?: string;\n}) {\n  try {\n    // 先获取文件信息，获取 sha\n    const fileInfo = await getFiles({ path, repo });\n    // getFiles 可能返回数组（目录）或对象（文件），需要检查类型\n    const sha = fileInfo && !Array.isArray(fileInfo) ? fileInfo.sha : undefined;\n\n    // 调用 uploadFile 上传文件\n    return await uploadFile({\n      file: content,\n      filename: path.split('/').pop() || path,\n      sha,\n      message: message || `Update ${path}`,\n      repo,\n      path: path.substring(0, path.lastIndexOf('/'))\n    });\n  } catch (error) {\n    toast({\n      title: '更新文件失败',\n      description: (error as GiteaError).message || '更新文件时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 获取 Gitea 仓库文件列表\n * @param params 查询参数\n */\nexport async function getFiles({ path, repo, sha }: { path: string; repo: string; sha?: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n\n    if (!giteaUsername) {\n      return null;\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 对路径进行 URL 编码，处理特殊字符\n    const encodedPath = path.replace(/\\s/g, '_').split('/').map(encodeURIComponent).join('/');\n    // Gitea API 使用 sha 参数来获取特定 commit/branch 的文件内容\n    const shaParam = sha ? `?sha=${sha}` : '';\n    const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${encodedPath}${shaParam}`;\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n\n      // 如果是单个文件，返回文件信息（包含 content）\n      if (!Array.isArray(data)) {\n        return {\n          name: data.name,\n          path: data.path,\n          type: data.type === 'dir' ? 'dir' : 'file',\n          sha: data.sha,\n          content: data.content || '', // 文件内容（base64）\n        };\n      }\n\n      // 如果是目录，返回文件列表\n      return data.map((item: GiteaDirectoryItem) => {\n        return {\n          name: item.name,\n          path: item.path,\n          type: item.type === 'dir' ? 'dir' : 'file',\n          sha: item.sha,\n        }\n      })\n    }\n\n    // 文件或目录不存在，返回 null\n    if (response.status === 404) {\n      return null\n    }\n\n    // 401 或其他客户端错误，抛出错误\n    if (response.status >= 400 && response.status < 500) {\n      const errorData = await response.json().catch(() => ({}));\n      throw {\n        status: response.status,\n        message: errorData.message || `获取文件列表失败: ${response.status}`\n      } as GiteaError;\n    }\n\n    return null;\n\n  } catch (error) {\n    // 重新抛出已处理的错误，静默处理其他错误\n    if ((error as GiteaError).status) {\n      throw error;\n    }\n    return null;\n  }\n}\n\n/**\n * 删除 Gitea 仓库文件\n * @param params 删除参数\n */\nexport async function deleteFile({ path, sha, repo }: { path: string; sha?: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n    \n    if (!giteaUsername) {\n      throw new Error('用户名未配置');\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 如果没有 sha，先获取文件信息\n    let fileSha = sha;\n    if (!fileSha) {\n      const fileUrl = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${path}`;\n      const fileResponse = await fetch(fileUrl, {\n        method: 'GET',\n        headers,\n        proxy\n      });\n      \n      if (fileResponse.ok) {\n        const fileData = await fileResponse.json() as GiteaFileContent;\n        fileSha = fileData.sha;\n      }\n    }\n\n    const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${path}`;\n    \n    const response = await fetch(url, {\n      method: 'DELETE',\n      headers,\n      body: JSON.stringify({\n        branch: 'main',\n        message: `Delete ${path}`,\n        sha: fileSha\n      }),\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      return true\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '删除文件失败'\n    } as GiteaError;\n\n  } catch (error) {\n    toast({\n      title: '删除文件失败',\n      description: (error as GiteaError).message || '删除文件时发生错误',\n      variant: 'destructive',\n    });\n    return null; // 确保在错误情况下也有返回值\n  }\n}\n\n/**\n * 获取文件提交历史\n * @param params 查询参数\n */\nexport async function getFileCommits({ path, repo }: { path: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n    \n    if (!giteaUsername) {\n      return false;\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // Gitea API 需要指定分支（sha 参数），默认使用 main 分支\n    // 对 path 进行编码，避免特殊字符导致 404\n    const encodedPath = encodeURIComponent(path);\n    const url = `${baseUrl}/repos/${giteaUsername}/${repo}/commits?sha=main&path=${encodedPath}&per_page=100`;\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GiteaCommit[];\n      return { data } as GiteaResponse<GiteaCommit[]>;\n    }\n    \n    // 404 或其他错误，静默返回 false（文件没有提交历史）\n    return false;\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    // 静默处理错误，不显示 toast\n    return false;\n  }\n}\n\n/**\n * 获取特定 commit 的文件内容\n * @param params 查询参数\n */\n/**\n * 获取特定 commit 的文件内容（通过 Git tree API）\n * @param params 查询参数\n */\nexport async function getFileContentFromCommit({ path, ref, repo }: { path: string; ref: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n\n    if (!giteaUsername) {\n      throw new Error('用户名未配置');\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 先获取 commit 信息，获取 tree SHA\n    const commitUrl = `${baseUrl}/repos/${giteaUsername}/${repo}/git/commits/${ref}`;\n\n    const commitResponse = await fetch(commitUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (!commitResponse.ok) {\n      return null;\n    }\n\n    const commitData = await commitResponse.json();\n    // tree SHA 在 commit.tree.sha\n    const treeSha = commitData.commit?.tree?.sha || commitData.tree?.sha;\n\n    if (!treeSha) {\n      return null;\n    }\n\n    // 获取文件在 tree 中的路径\n    const safePath = path.replace(/\\s/g, '_');\n    const treeUrl = `${baseUrl}/repos/${giteaUsername}/${repo}/git/trees/${treeSha}?recursive=1`;\n\n    const treeResponse = await fetch(treeUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (!treeResponse.ok) {\n      return null;\n    }\n\n    const treeData = await treeResponse.json();\n    // 查找目标文件\n    const fileEntry = treeData.tree?.find((item: any) => item.path === safePath);\n\n    if (!fileEntry || fileEntry.type !== 'blob') {\n      return null;\n    }\n\n    // 获取文件内容\n    const blobUrl = `${baseUrl}/repos/${giteaUsername}/${repo}/git/blobs/${fileEntry.sha}`;\n\n    const blobResponse = await fetch(blobUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (!blobResponse.ok) {\n      return null;\n    }\n\n    const blobData = await blobResponse.json();\n\n    return {\n      content: blobData.content || '',\n      encoding: blobData.encoding || 'base64'\n    };\n\n  } catch {\n    return null;\n  }\n}\n\nexport async function getFileContent({ path, ref, repo }: { path: string; ref: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n    \n    if (!giteaUsername) {\n      throw new Error('用户名未配置');\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 获取特定 commit 的文件内容，对 path 进行编码\n    // 与 getFiles 保持一致：对每个路径部分分别进行编码\n    const encodedPath = path.replace(/\\s/g, '_').split('/').map(encodeURIComponent).join('/');\n    // Gitea API 使用 sha 参数而不是 ref 参数来获取特定 commit 的文件内容\n    const url = `${baseUrl}/repos/${giteaUsername}/${repo}/contents/${encodedPath}?sha=${ref}`;\n\n    const response = await encodeFetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GiteaFileContent;\n      return {\n        content: data.content || '',\n        encoding: data.encoding || 'base64'\n      };\n    }\n\n    if (response.status >= 400 && response.status < 500) {\n      return {\n        content: '',\n        encoding: 'base64'\n      }\n    }\n\n    const errorData = await response.text();\n    throw {\n      status: response.status,\n      message: errorData || '获取文件内容失败'\n    } as GiteaError;\n\n  } catch (error) {\n    toast({\n      title: '获取文件内容失败',\n      description: (error as GiteaError).message || '获取文件内容时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 获取 Gitea 用户信息\n * @param token 可选的访问令牌\n */\nexport async function getUserInfo(token?: string): Promise<GiteaUserInfo> {\n  try {\n    const store = await Store.load('store.json');\n    const accessToken = token || await store.get<string>('giteaAccessToken');\n    \n    if (!accessToken) {\n      throw new Error('访问令牌未配置');\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const proxy = await getProxyConfig();\n\n    const headers = new Headers();\n    headers.append('Authorization', `token ${accessToken}`);\n    headers.append('Content-Type', 'application/json');\n\n    const response = await fetch(`${baseUrl}/user`, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const userInfo = await response.json() as GiteaUserInfo;\n      \n      // 保存用户名到存储\n      await store.set('giteaUsername', userInfo.login);\n      await store.save();\n      \n      return userInfo;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '获取用户信息失败'\n    } as GiteaError;\n\n  } catch (error) {\n    toast({\n      title: '获取用户信息失败',\n      description: (error as GiteaError).message || '获取用户信息时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 检查同步仓库状态\n * @param name 仓库名称\n */\nexport async function checkSyncRepoState(name: string): Promise<GiteaRepositoryInfo | null> {\n  try {\n    const store = await Store.load('store.json');\n    const giteaUsername = await store.get<string>('giteaUsername');\n    \n    if (!giteaUsername) {\n      throw new Error('用户名未配置');\n    }\n\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 直接尝试获取仓库信息\n    const repoUrl = `${baseUrl}/repos/${giteaUsername}/${name}`;\n    \n    const response = await fetch(repoUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const repo = await response.json() as GiteaRepositoryInfo;\n      return repo;\n    }\n\n    if (response.status === 404) {\n      return null;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '检查仓库状态失败'\n    } as GiteaError;\n\n  } catch (error) {\n    throw error;\n  }\n}\n\n/**\n * 创建同步仓库\n * @param name 仓库名称\n * @param isPrivate 是否私有仓库\n */\nexport async function createSyncRepo(name: string, isPrivate: boolean = true): Promise<GiteaRepositoryInfo | null> {\n  try {\n    const baseUrl = await getGiteaApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    const requestBody = {\n      name: name,\n      description: `note-gen 同步仓库 - ${name}`,\n      private: isPrivate,\n      auto_init: true,\n      default_branch: 'main'\n    };\n\n    const response = await fetch(`${baseUrl}/user/repos`, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify(requestBody),\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const repo = await response.json() as GiteaRepositoryInfo;\n      return repo;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '创建仓库失败'\n    } as GiteaError;\n\n  } catch (error) {\n    toast({\n      title: '创建仓库失败',\n      description: (error as GiteaError).message || '创建仓库时发生错误',\n      variant: 'destructive',\n    });\n    return null;\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/gitea.types.ts",
    "content": "// Gitea 实例类型枚举\nexport enum GiteaInstanceType {\n  OFFICIAL = 'gitea.com',      // 官方实例\n  SELF_HOSTED = 'self-hosted'   // 自建实例\n}\n\n// Gitea 实例配置\nexport interface GiteaInstanceConfig {\n  type: GiteaInstanceType;\n  baseUrl: string;\n  name: string;\n  description: string;\n}\n\n// 预定义的 Gitea 实例配置\nexport const GITEA_INSTANCES: Record<GiteaInstanceType, GiteaInstanceConfig> = {\n  [GiteaInstanceType.OFFICIAL]: {\n    type: GiteaInstanceType.OFFICIAL,\n    baseUrl: 'https://gitea.com',\n    name: 'Gitea.com',\n    description: '官方 Gitea 实例'\n  },\n  [GiteaInstanceType.SELF_HOSTED]: {\n    type: GiteaInstanceType.SELF_HOSTED,\n    baseUrl: '',\n    name: '自建实例',\n    description: '自建 Gitea 服务器'\n  }\n};\n\n// Gitea 错误类型\nexport interface GiteaError {\n  status: number;\n  message: string;\n}\n\n// Gitea 用户信息类型\nexport interface GiteaUserInfo {\n  id: number;\n  login: string;\n  full_name: string;\n  email: string;\n  avatar_url: string;\n  html_url?: string; // 用户主页 URL\n  language: string;\n  is_admin: boolean;\n  last_login: string;\n  created: string;\n  restricted: boolean;\n  active: boolean;\n  prohibit_login: boolean;\n  location: string;\n  website: string;\n  description: string;\n  visibility: string;\n  followers_count: number;\n  following_count: number;\n  starred_repos_count: number;\n  username: string;\n}\n\n// Gitea 仓库信息类型\nexport interface GiteaRepositoryInfo {\n  id: number;\n  owner: {\n    id: number;\n    login: string;\n    full_name: string;\n    email: string;\n    avatar_url: string;\n    language: string;\n    is_admin: boolean;\n    last_login: string;\n    created: string;\n    restricted: boolean;\n    active: boolean;\n    prohibit_login: boolean;\n    location: string;\n    website: string;\n    description: string;\n    visibility: string;\n    followers_count: number;\n    following_count: number;\n    starred_repos_count: number;\n    username: string;\n  };\n  name: string;\n  full_name: string;\n  description: string;\n  empty: boolean;\n  private: boolean;\n  fork: boolean;\n  template: boolean;\n  parent: null;\n  mirror: boolean;\n  size: number;\n  language: string;\n  languages_url: string;\n  html_url: string;\n  ssh_url: string;\n  clone_url: string;\n  original_url: string;\n  website: string;\n  stars_count: number;\n  forks_count: number;\n  watchers_count: number;\n  open_issues_count: number;\n  open_pr_counter: number;\n  release_counter: number;\n  default_branch: string;\n  archived: boolean;\n  created_at: string;\n  updated_at: string;\n  permissions: {\n    admin: boolean;\n    push: boolean;\n    pull: boolean;\n  };\n  has_issues: boolean;\n  internal_tracker: {\n    enable_time_tracker: boolean;\n    allow_only_contributors_to_track_time: boolean;\n    enable_issue_dependencies: boolean;\n  };\n  has_wiki: boolean;\n  has_pull_requests: boolean;\n  has_projects: boolean;\n  ignore_whitespace_conflicts: boolean;\n  allow_merge_commits: boolean;\n  allow_rebase: boolean;\n  allow_rebase_explicit: boolean;\n  allow_squash_merge: boolean;\n  default_merge_style: string;\n  avatar_url: string;\n  internal: boolean;\n  mirror_interval: string;\n  mirror_updated: string;\n}\n\n// Gitea 文件内容类型\nexport interface GiteaFileContent {\n  name: string;\n  path: string;\n  sha: string;\n  size: number;\n  url: string;\n  html_url: string;\n  git_url: string;\n  download_url: string;\n  type: string;\n  content?: string;  // base64 编码的内容\n  encoding?: string;\n  _links: {\n    self: string;\n    git: string;\n    html: string;\n  };\n}\n\n// Gitea 目录内容类型\nexport interface GiteaDirectoryItem {\n  name: string;\n  path: string;\n  sha: string;\n  size: number;\n  url: string;\n  html_url: string;\n  git_url: string;\n  download_url: string;\n  type: 'file' | 'dir';\n  _links: {\n    self: string;\n    git: string;\n    html: string;\n  };\n}\n\n// Gitea 提交信息类型\nexport interface GiteaCommit {\n  sha: string;\n  commit: {\n    author: {\n      name: string;\n      email: string;\n      date: string;\n    };\n    committer: {\n      name: string;\n      email: string;\n      date: string;\n    };\n    message: string;\n    tree: {\n      sha: string;\n      url: string;\n    };\n    verification: {\n      verified: boolean;\n      reason: string;\n      signature: string;\n      payload: string;\n    };\n  };\n  url: string;\n  html_url: string;\n  parents: Array<{\n    sha: string;\n    url: string;\n  }>;\n  author: {\n    id: number;\n    login: string;\n    full_name: string;\n    email: string;\n    avatar_url: string;\n    language: string;\n    is_admin: boolean;\n    last_login: string;\n    created: string;\n    username: string;\n  };\n  committer: {\n    id: number;\n    login: string;\n    full_name: string;\n    email: string;\n    avatar_url: string;\n    language: string;\n    is_admin: boolean;\n    last_login: string;\n    created: string;\n    username: string;\n  };\n  created: string;\n}\n\n// Gitea API 响应类型\nexport type GiteaResponse<T> = {\n  data: T;\n  status?: number;\n  headers?: Record<string, string>;\n}\n\n// 同步状态枚举（复用现有的）\nexport { SyncStateEnum } from './github.types';\n\n// 仓库名称枚举（复用现有的）\nexport { RepoNames } from './github.types';\n"
  },
  {
    "path": "src/lib/sync/gitee.ts",
    "content": "import { toast } from '@/hooks/use-toast';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { v4 as uuid } from 'uuid';\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { buildRepoContentPath, buildRepoContentsEndpoint, pickNestedFileEntry } from './remote-file'\nexport { decodeBase64ToString } from './remote-file'\n// Remove unused imports - these types are not actually used in this file\n\n// 自定义类型，类似于 GitHub 的响应\ntype GiteeResponse<T> = {\n  data: T;\n  status?: number;\n  headers?: Record<string, string>;\n}\n\n// File 转换 Base64\nexport async function fileToBase64(file: File) {\n  return new Promise<string>((resolve, reject) => {\n    const reader = new FileReader();\n    reader.readAsDataURL(file);\n    reader.onload = () => {\n      // 删除前缀\n      const base64 = reader.result?.toString().replace(/^data:image\\/\\w+;base64,/, '');\n      resolve(base64 || '');\n    }\n    reader.onerror = error => reject(error);\n  });\n}\n\n// Gitee Error 类型，与 GitHub 保持一致\nexport interface GiteeError {\n  status: number;\n  message: string;\n}\n\n// Gitee 仓库信息类型\nexport interface GiteeRepoInfo {\n  id: number;\n  full_name: string;\n  human_name: string;\n  url: string;\n  namespace: {\n    id: number;\n    name: string;\n    path: string;\n  };\n  path: string;\n  name: string;\n  owner: {\n    id: number;\n    login: string;\n    name: string;\n    avatar_url: string;\n    url: string;\n    html_url: string;\n    remark: string;\n    followers_url: string;\n    following_url: string;\n    gists_url: string;\n    starred_url: string;\n    subscriptions_url: string;\n    organizations_url: string;\n    repos_url: string;\n    events_url: string;\n    received_events_url: string;\n    type: string;\n  };\n  private: boolean;\n  html_url: string;\n  description: string;\n  fork: boolean;\n  created_at: string;\n  updated_at: string;\n  pushed_at: string;\n  homepage: string;\n  stargazers_count: number;\n  watchers_count: number;\n  forks_count: number;\n  language: string;\n  default_branch: string;\n  open_issues_count: number;\n  license: {\n    key: string;\n    name: string;\n    spdx_id: string;\n    url: string;\n  } | null;\n  topics: string[];\n  has_issues: boolean;\n  has_wiki: boolean;\n  has_pages: boolean;\n  issue_comment: boolean;\n  can_comment: boolean;\n  repository_type: string;\n  permissions: {\n    admin: boolean;\n    push: boolean;\n    pull: boolean;\n  };\n}\n\nexport interface GiteeFile {\n  name: string;\n  path: string;\n  sha: string;\n  size: number;\n  url: string;\n  html_url: string;\n  download_url: string;\n  type: string;\n  _links: Links;\n  isNew?: boolean;\n}\n\ninterface Links {\n  self: string;\n  html: string;\n}\n\nfunction looksLikeFilePath(path?: string) {\n  const lastSegment = path?.split('/').filter(Boolean).pop() || ''\n  return lastSegment.includes('.')\n}\n\nexport async function uploadFile(\n  { file, filename, sha, message, repo, path }:\n  { file: string, filename?: string, sha?: string, message?: string, repo: string, path?: string })\n{\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('giteeAccessToken')\n  const giteeUsername = await store.get('giteeUsername')\n  const id = uuid()\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    let targetPath = path\n    let resolvedExistingFile: GiteeFile | null = null\n    if (path) {\n      const existingFile = await getFiles({ path, repo })\n      if (existingFile && !Array.isArray(existingFile)) {\n        resolvedExistingFile = existingFile\n        targetPath = existingFile.path || path\n        sha = existingFile.sha || sha\n      }\n    }\n\n    const finalPath = resolvedExistingFile\n      ? buildRepoContentPath({ path: targetPath })\n      : targetPath\n      ? buildRepoContentPath({ path: targetPath, filename })\n      : buildRepoContentPath({ filename: filename || id })\n\n    // 将内容转换为 Base64（Gitee API 要求）\n    const base64Content = Buffer.from(file, 'utf-8').toString('base64')\n\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Content-Type', 'application/json');\n\n    // 根据是否有sha参数来决定是创建新文件（POST）还是更新文件（PUT）\n    // Gitee API 与 GitHub 不同，更新文件需要使用 PUT 请求\n    const requestOptions = {\n      method: sha ? 'PUT' : 'POST',\n      headers,\n      body: JSON.stringify({\n        access_token: accessToken,\n        content: base64Content,\n        message: message || `Upload ${filename || id}`,\n        branch: 'master',\n        sha\n      }),\n      proxy\n    };\n\n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}${buildRepoContentsEndpoint(finalPath)}`;\n    const response = await fetch(url, requestOptions);\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return { data } as GiteeResponse<any>;\n    }\n\n    if (response.status === 400) {\n      return null;\n    }\n\n    // 404 表示文件不存在，尝试用 POST 创建新文件\n    if (response.status === 404) {\n      const postOptions = {\n        method: 'POST',\n        headers,\n        body: JSON.stringify({\n          access_token: accessToken,\n          content: base64Content,\n          message: message || `Upload ${filename || id}`,\n          branch: 'master',\n        }),\n        proxy\n      };\n      const postResponse = await fetch(url, postOptions);\n      if (postResponse.status >= 200 && postResponse.status < 300) {\n        const data = await postResponse.json();\n        return { data } as GiteeResponse<any>;\n      }\n      const postErrorData = await postResponse.json();\n      throw {\n        status: postResponse.status,\n        message: postErrorData.message || '同步失败'\n      };\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '同步失败'\n    };\n  } catch (error) {\n    toast({\n      title: '同步失败',\n      description: (error as GiteeError).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nexport async function getFiles({ path, repo, ref }: { path: string, repo: string, ref?: string }) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('giteeAccessToken')\n  if (!accessToken) return;\n\n  const giteeUsername = await store.get<string>('giteeUsername')\n  const normalizedPath = buildRepoContentPath({ path })\n\n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n\n  try {\n    // 构建 URL 参数\n    let urlParams = `access_token=${accessToken}`\n    if (ref) {\n      urlParams += `&ref=${ref}`\n    }\n\n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}${buildRepoContentsEndpoint(normalizedPath)}?${urlParams}`;\n    \n    const requestOptions = {\n      method: 'GET',\n      proxy\n    };\n    \n    try {\n      const response = await fetch(url, requestOptions);\n      if (response.status >= 200 && response.status < 300) {\n        const data = await response.json();\n        if (Array.isArray(data) && looksLikeFilePath(path)) {\n          const nestedFile = pickNestedFileEntry(data, path)\n          if (nestedFile?.path && nestedFile.path !== path) {\n            return await getFiles({ path: nestedFile.path, repo, ref })\n          }\n        }\n        return data;\n      }\n      return null;\n    } catch {\n      return null;\n    }\n  } catch (error) {\n    if ((error as GiteeError).status !== 404) {\n      toast({\n        title: '查询失败',\n        description: (error as GiteeError).message,\n        variant: 'destructive',\n      })\n    }\n  }\n}\n\nexport async function deleteFile({ path, sha, repo }: { path: string, sha: string, repo: string }) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('giteeAccessToken')\n  if (!accessToken) return;\n  \n  const giteeUsername = await store.get('giteeUsername')\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Content-Type', 'application/json');\n    \n    const requestOptions = {\n      method: 'DELETE',\n      headers,\n      body: JSON.stringify({\n        access_token: accessToken,\n        sha,\n        message: `Delete ${path}`\n      }),\n      proxy\n    };\n    \n    const normalizedPath = buildRepoContentPath({ path });\n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}${buildRepoContentsEndpoint(normalizedPath)}`;\n    \n    const response = await fetch(url, requestOptions);\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return { data } as GiteeResponse<any>;\n    }\n    \n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '删除失败'\n    };\n  } catch (error) {\n    toast({\n      title: '删除失败',\n      description: (error as GiteeError).message,\n      variant: 'destructive',\n    })\n    // 返回 false 而不是 undefined，让调用者知道操作已完成\n    return false;\n  }\n}\n\nexport async function getFileCommits({ path, repo }: { path: string, repo: string }) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('giteeAccessToken')\n  if (!accessToken) return;\n  \n  const giteeUsername = await store.get<string>('giteeUsername')\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求参数\n    const params = new URLSearchParams();\n    params.append('access_token', accessToken);\n    params.append('path', path);\n    params.append('per_page', '100');\n    \n    const requestOptions = {\n      method: 'GET',\n      proxy\n    };\n    \n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${repo}/commits?${params.toString()}`;\n    \n    const response = await fetch(url, requestOptions);\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data\n    }\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return false\n  }\n}\n\n// 获取 Gitee 用户信息\nexport async function getUserInfo() {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('giteeAccessToken')\n  if (!accessToken) {\n    return;\n  }\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求参数\n    const params = new URLSearchParams();\n    params.append('access_token', accessToken);\n    \n    const requestOptions = {\n      method: 'GET',\n      proxy,\n      // 添加超时设置\n      timeout: 10000 // 10秒超时\n    };\n    \n    const url = `https://gitee.com/api/v5/user?${params.toString()}`;\n    \n    const response = await fetch(url, requestOptions);\n    const data = await response.json();\n    \n    // 保存用户名到存储\n    await store.set('giteeUsername', data.login);\n    \n    return data;\n  } catch {\n    // 不显示 toast，避免在检测过程中干扰用户\n    throw {\n      status: 0,\n      message: '获取用户信息失败'\n    };\n  }\n}\n\n// 检查 Gitee 仓库\nexport async function checkSyncRepoState(name: string) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('giteeAccessToken')\n  if (!accessToken) {\n    return;\n  }\n  \n  const giteeUsername = await store.get<string>('giteeUsername')\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求参数\n    const params = new URLSearchParams();\n    params.append('access_token', accessToken);\n    \n    const requestOptions = {\n      method: 'GET',\n      proxy,\n      // 添加超时设置\n      timeout: 10000 // 10秒超时\n    };\n    \n    const url = `https://gitee.com/api/v5/repos/${giteeUsername}/${name}?${params.toString()}`;\n    \n    const response = await fetch(url, requestOptions);\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data;\n    }\n    \n    throw {\n      status: response.status,\n      message: '仓库不存在'\n    };\n  } catch (error) {\n    if ((error as GiteeError).status === 404) {\n      return null;\n    }\n    throw error;\n  }\n}\n\n// 创建 Gitee 仓库\nexport async function createSyncRepo(name: string, isPrivate?: boolean) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('giteeAccessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Content-Type', 'application/json');\n    \n    const requestOptions = {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({\n        access_token: accessToken,\n        name,\n        private: isPrivate === undefined ? true : isPrivate,\n        auto_init: false,\n        description: '由 Note Gen 自动创建'\n      }),\n      proxy\n    };\n    \n    const url = `https://gitee.com/api/v5/user/repos`;\n    \n    const response = await fetch(url, requestOptions);\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data;\n    }\n    \n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '创建仓库失败'\n    };\n  } catch (error) {\n    toast({\n      title: '创建仓库失败',\n      description: (error as GiteeError).message,\n      variant: 'destructive',\n    })\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/github.ts",
    "content": "import { toast } from '@/hooks/use-toast';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { v4 as uuid } from 'uuid';\nimport { GithubError, GithubRepoInfo, OctokitResponse } from './github.types';\nimport { fetch, Proxy } from '@tauri-apps/plugin-http'\nexport { decodeBase64ToString } from './remote-file';\n\nexport function uint8ArrayToBase64(data: Uint8Array) {\n  return Buffer.from(data).toString('base64');\n}\n\n// File 转换 Base64\nexport async function fileToBase64(file: File) {\n  return new Promise<string>((resolve, reject) => {\n    const reader = new FileReader();\n    reader.readAsDataURL(file);\n    reader.onload = () => {\n      // 删除前缀\n      const base64 = reader.result?.toString().replace(/^data:image\\/\\w+;base64,/, '');\n      resolve(base64 || '');\n    }\n    reader.onerror = error => reject(error);\n  });\n}\n\nexport interface GithubFile {\n  name: string;\n  path: string;\n  sha: string;\n  size: number;\n  url: string;\n  html_url: string;\n  git_url: string;\n  download_url: string;\n  type: string;\n  _links: Links;\n  isNew?: boolean;\n}\n\ninterface Links {\n  self: string;\n  git: string;\n  html: string;\n}\n\nexport async function uploadFile(\n  { file, filename, sha, message, repo, path }:\n  { file: string, filename?: string, sha?: string, message?: string, repo: string, path?: string })\n{\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('accessToken')\n  const githubUsername = await store.get('githubUsername')\n  const id = uuid()\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 构建路径，将空格转换成下划线\n    const _path = path ? `/${path.replace(/\\s/g, '_')}` : ''\n\n    // 对 URL 路径进行编码（保留中文字符的 UTF-8 编码）\n    const urlPath = _path.split('/').map(segment => encodeURIComponent(segment)).join('/')\n\n    // 将内容转换为 Base64（GitHub API 要求）\n    const base64Content = Buffer.from(file, 'utf-8').toString('base64')\n\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('Content-Type', 'application/json');\n\n    const requestOptions = {\n      method: 'PUT',\n      headers,\n      body: JSON.stringify({\n        message: message || `Upload ${filename || id}`,\n        content: base64Content,\n        sha\n      }),\n      proxy\n    };\n\n    const url = `https://api.github.com/repos/${githubUsername}/${repo}/contents${urlPath}`;\n    const response = await fetch(url, requestOptions);\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return { data } as OctokitResponse<any>;\n    }\n\n    if (response.status === 400) {\n      return null;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '同步失败'\n    };\n  } catch (error) {\n    toast({\n      title: '同步失败',\n      description: (error as GithubError).message,\n      variant: 'destructive',\n    })\n  }\n}\n\nexport async function getFiles({ path, repo, ref }: { path: string, repo: string, ref?: string }) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('accessToken')\n  if (!accessToken) return;\n\n  const githubUsername = await store.get('githubUsername')\n\n  // 只对空格进行转义，保留中文字符的原始 UTF-8 编码\n  const safePath = path.replace(/\\s/g, '_')\n\n  // 对 URL 路径进行编码\n  const encodedPath = safePath.split('/').map(segment => encodeURIComponent(segment)).join('/')\n\n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n\n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('If-None-Match', '');\n\n    const requestOptions = {\n      method: 'GET',\n      headers,\n      proxy\n    };\n\n    // 如果有 ref 参数，添加到 URL 查询参数中\n    const refParam = ref ? `?ref=${ref}` : '';\n    const url = `https://api.github.com/repos/${githubUsername}/${repo}/contents/${encodedPath}${refParam}`;\n    \n    try {\n      const response = await fetch(url, requestOptions);\n      if (response.status >= 200 && response.status < 300) {\n        const data = await response.json();\n        return data;\n      }\n      return null;\n    } catch {\n      return null;\n    }\n  } catch (error) {\n    if ((error as GithubError).status !== 404) {\n      toast({\n        title: '查询失败',\n        description: (error as GithubError).message,\n        variant: 'destructive',\n      })\n    }\n  }\n}\n\nexport async function deleteFile(\n  { path, sha, repo, token, username }: \n  { path: string, sha: string, repo: string, token?: string, username?: string }\n) {\n  const store = await Store.load('store.json');\n  const accessToken = token || await store.get('accessToken')\n  if (!accessToken) return;\n  \n  const githubUsername = username || await store.get('githubUsername')\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('Content-Type', 'application/json');\n    \n    const requestOptions = {\n      method: 'DELETE',\n      headers,\n      body: JSON.stringify({\n        sha,\n        message: `Delete ${path}`\n      }),\n      proxy\n    };\n\n    // 分离路径和文件名，只对路径部分进行编码，保留文件名的原始字符\n    const lastSlashIndex = path.lastIndexOf('/')\n    const dirPath = lastSlashIndex > 0 ? path.substring(0, lastSlashIndex) : ''\n    const fileName = lastSlashIndex > 0 ? path.substring(lastSlashIndex + 1) : path\n\n    // 对目录路径进行编码，但保留文件名不变\n    const encodedPath = dirPath ? encodeURIComponent(dirPath) + '/' + fileName : fileName\n\n    const url = `https://api.github.com/repos/${githubUsername}/${repo}/contents/${encodedPath}`;\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data;\n    }\n\n    throw new Error(`删除文件失败: ${response.status} ${response.statusText}`);\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return false\n  }\n}\n\nexport async function getFileCommits({ path, repo }: { path: string, repo: string }) {\n  if (!path) return;\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('accessToken')\n  if (!accessToken) return;\n\n  const githubUsername = await store.get('githubUsername')\n\n  // 只对空格进行转义，保留中文字符的原始 UTF-8 编码\n  const safePath = path.replace(/\\s/g, '_')\n\n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n\n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('If-None-Match', '');\n\n    const requestOptions = {\n      method: 'GET',\n      headers,\n      proxy\n    };\n    \n    const url = `https://api.github.com/repos/${githubUsername}/${repo}/commits?path=${encodeURIComponent(safePath)}&per_page=100`;\n    const response = await fetch(url, requestOptions);\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data;\n    }\n\n    if (response.status === 404) {\n      return [];\n    }\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return false\n  }\n}\n\n// 获取 Github 用户信息\nexport async function getUserInfo(token?: string) {\n  const store = await Store.load('store.json');\n  const accessToken = token || await store.get('accessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    \n    const requestOptions = {\n      method: 'GET',\n      headers,\n      proxy\n    };\n    \n    const url = 'https://api.github.com/user';\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      await store.set('githubUsername', data.login);\n      return { data } as OctokitResponse<any>;\n    }\n    \n    throw new Error('获取用户信息失败');\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return false;\n  }\n}\n\n// 检查 Github 仓库\nexport async function checkSyncRepoState(name: string) {\n  const store = await Store.load('store.json');\n  const githubUsername = await store.get('githubUsername')\n  const accessToken = await store.get('accessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  // 设置请求头\n  const headers = new Headers();\n  headers.append('Authorization', `Bearer ${accessToken}`);\n  headers.append('Accept', 'application/vnd.github+json');\n  headers.append('X-GitHub-Api-Version', '2022-11-28');\n  \n  const requestOptions = {\n    method: 'GET',\n    headers,\n    proxy\n  };\n  \n  const url = `https://api.github.com/repos/${githubUsername}/${name}`;\n  const response = await fetch(url, requestOptions);\n  \n  if (response.status >= 200 && response.status < 300) {\n    const data = await response.json();\n    return data;\n  }\n  \n  return false\n}\n\n// 创建 Github 仓库\nexport async function createSyncRepo(name: string, isPrivate?: boolean) {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('accessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('Content-Type', 'application/json');\n    \n    const requestOptions = {\n      method: 'POST',\n      headers,\n      body: JSON.stringify({\n        name,\n        description: 'This is a NoteGen sync repository.',\n        private: isPrivate\n      }),\n      proxy\n    };\n    \n    const url = 'https://api.github.com/user/repos';\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GithubRepoInfo;\n      return data;\n    }\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return undefined;\n  }\n}\n\n// 读取 release\nexport async function getRelease() {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get('accessToken')\n  if (!accessToken) return;\n  \n  // 获取代理设置\n  const proxyUrl = await store.get<string>('proxy')\n  const proxy: Proxy | undefined = proxyUrl ? {\n    all: proxyUrl\n  } : undefined\n  \n  try {\n    // 设置请求头\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Accept', 'application/vnd.github+json');\n    headers.append('X-GitHub-Api-Version', '2022-11-28');\n    headers.append('If-None-Match', '');\n    \n    const requestOptions = {\n      method: 'GET',\n      headers,\n      proxy\n    };\n    \n    const url = `https://api.github.com/repos/codexu/note-gen/releases/latest`;\n    const response = await fetch(url, requestOptions);\n    \n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json();\n      return data;\n    }\n    \n    throw new Error('获取 release 失败');\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    return false\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/github.types.ts",
    "content": "export enum RepoNames {\n  image = 'note-gen-image-sync',\n  sync = 'note-gen-sync'\n}\n\nexport interface GithubError {\n  message: string\n  documentation_url: string\n  status: number\n}\n\nexport interface UserInfo {\n  login: string;\n  id: number;\n  node_id: string;\n  avatar_url: string;\n  gravatar_id: string;\n  url: string;\n  html_url: string;\n  followers_url: string;\n  following_url: string;\n  gists_url: string;\n  starred_url: string;\n  subscriptions_url: string;\n  organizations_url: string;\n  repos_url: string;\n  events_url: string;\n  received_events_url: string;\n  type: string;\n  user_view_type: string;\n  site_admin: boolean;\n  name: string;\n  company: null;\n  blog: string;\n  location: string;\n  email: string;\n  hireable: boolean;\n  bio: null;\n  twitter_username: null;\n  notification_email: string;\n  public_repos: number;\n  public_gists: number;\n  followers: number;\n  following: number;\n  created_at: string;\n  updated_at: string;\n}\n\nexport interface ResCommit {\n  sha: string;\n  node_id: string;\n  commit: Commit;\n  url: string;\n  html_url: string;\n  comments_url: string;\n  author: Author2;\n  committer: Author2;\n  parents: Parent[];\n}\n\ninterface Parent {\n  sha: string;\n  url: string;\n  html_url: string;\n}\n\ninterface Author2 {\n  login: string;\n  id: number;\n  node_id: string;\n  avatar_url: string;\n  gravatar_id: string;\n  url: string;\n  html_url: string;\n  followers_url: string;\n  following_url: string;\n  gists_url: string;\n  starred_url: string;\n  subscriptions_url: string;\n  organizations_url: string;\n  repos_url: string;\n  events_url: string;\n  received_events_url: string;\n  type: string;\n  user_view_type: string;\n  site_admin: boolean;\n}\n\ninterface Commit {\n  author: Author;\n  committer: Author;\n  message: string;\n  tree: Tree;\n  url: string;\n  comment_count: number;\n  verification: Verification;\n}\n\ninterface Verification {\n  verified: boolean;\n  reason: string;\n  signature: null;\n  payload: null;\n  verified_at: null;\n}\n\ninterface Tree {\n  sha: string;\n  url: string;\n}\n\ninterface Author {\n  name: string;\n  email: string;\n  date: string;\n}\n\nexport interface GithubContent {\n  name: string;\n  path: string;\n  sha: string;\n  size: number;\n  url: string;\n  html_url: string;\n  git_url: string;\n  download_url: null | string;\n  type: 'file' | 'dir';\n  _links: Links;\n}\n\ninterface Links {\n  self: string;\n  git: string;\n  html: string;\n}\n\nexport interface GithubRepoInfo {\n  id: number;\n  node_id: string;\n  name: string;\n  full_name: string;\n  private: boolean;\n  owner: Owner;\n  html_url: string;\n  description: string;\n  fork: boolean;\n  url: string;\n  forks_url: string;\n  keys_url: string;\n  collaborators_url: string;\n  teams_url: string;\n  hooks_url: string;\n  issue_events_url: string;\n  events_url: string;\n  assignees_url: string;\n  branches_url: string;\n  tags_url: string;\n  blobs_url: string;\n  git_tags_url: string;\n  git_refs_url: string;\n  trees_url: string;\n  statuses_url: string;\n  languages_url: string;\n  stargazers_url: string;\n  contributors_url: string;\n  subscribers_url: string;\n  subscription_url: string;\n  commits_url: string;\n  git_commits_url: string;\n  comments_url: string;\n  issue_comment_url: string;\n  contents_url: string;\n  compare_url: string;\n  merges_url: string;\n  archive_url: string;\n  downloads_url: string;\n  issues_url: string;\n  pulls_url: string;\n  milestones_url: string;\n  notifications_url: string;\n  labels_url: string;\n  releases_url: string;\n  deployments_url: string;\n  created_at: string;\n  updated_at: string;\n  pushed_at: string;\n  git_url: string;\n  ssh_url: string;\n  clone_url: string;\n  svn_url: string;\n  homepage: null;\n  size: number;\n  stargazers_count: number;\n  watchers_count: number;\n  language: null;\n  has_issues: boolean;\n  has_projects: boolean;\n  has_downloads: boolean;\n  has_wiki: boolean;\n  has_pages: boolean;\n  has_discussions: boolean;\n  forks_count: number;\n  mirror_url: null;\n  archived: boolean;\n  disabled: boolean;\n  open_issues_count: number;\n  license: null;\n  allow_forking: boolean;\n  is_template: boolean;\n  web_commit_signoff_required: boolean;\n  topics: any[];\n  visibility: string;\n  forks: number;\n  open_issues: number;\n  watchers: number;\n  default_branch: string;\n  permissions: Permissions;\n  temp_clone_token: string;\n  allow_squash_merge: boolean;\n  allow_merge_commit: boolean;\n  allow_rebase_merge: boolean;\n  allow_auto_merge: boolean;\n  delete_branch_on_merge: boolean;\n  allow_update_branch: boolean;\n  use_squash_pr_title_as_default: boolean;\n  squash_merge_commit_message: string;\n  squash_merge_commit_title: string;\n  merge_commit_message: string;\n  merge_commit_title: string;\n  network_count: number;\n  subscribers_count: number;\n}\n\ninterface Permissions {\n  admin: boolean;\n  maintain: boolean;\n  push: boolean;\n  triage: boolean;\n  pull: boolean;\n}\n\ninterface Owner {\n  login: string;\n  id: number;\n  node_id: string;\n  avatar_url: string;\n  gravatar_id: string;\n  url: string;\n  html_url: string;\n  followers_url: string;\n  following_url: string;\n  gists_url: string;\n  starred_url: string;\n  subscriptions_url: string;\n  organizations_url: string;\n  repos_url: string;\n  events_url: string;\n  received_events_url: string;\n  type: string;\n  user_view_type: string;\n  site_admin: boolean;\n}\n\nexport enum SyncStateEnum {\n  checking = '检测中',\n  success = '可用',\n  creating = '创建中',\n  fail = '不可用',\n}\n\n// 自定义类型，代替 OctokitResponse\nexport type OctokitResponse<T> = {\n  data: T;\n  status?: number;\n  headers?: Record<string, string>;\n}\n"
  },
  {
    "path": "src/lib/sync/gitlab.ts",
    "content": "import { toast } from '@/hooks/use-toast';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { v4 as uuid } from 'uuid';\nimport { fetch, Proxy } from '@tauri-apps/plugin-http';\nimport { fetch as encodeFetch } from './encode-fetch'\nimport { \n  GitlabInstanceType, \n  GitlabProjectInfo, \n  GITLAB_INSTANCES, \n  GitlabError,\n  GitlabUserInfo,\n  GitlabCommit,\n  GitlabResponse,\n  GitlabRepositoryFile\n} from './gitlab.types';\n\n// 获取 Gitlab 实例的 API 基础 URL \n\nasync function getGitlabApiBaseUrl(): Promise<string> {\n  const store = await Store.load('store.json');\n  const instanceType = await store.get<GitlabInstanceType>('gitlabInstanceType') || GitlabInstanceType.OFFICIAL;\n\n  if (instanceType === GitlabInstanceType.SELF_HOSTED) {\n    let customUrl = await store.get<string>('gitlabCustomUrl') || '';\n    // 移除末尾的斜杠，避免双斜杠问题\n    customUrl = customUrl.replace(/\\/+$/, '').trim();\n\n    // 验证自定义 URL 是否有效\n    if (!customUrl) {\n      throw new Error('自建 GitLab 实例的 URL 未配置，请先在设置中填写 GitLab URL');\n    }\n\n    // 确保 URL 包含协议\n    if (!customUrl.startsWith('http://') && !customUrl.startsWith('https://')) {\n      customUrl = 'https://' + customUrl;\n    }\n\n    return `${customUrl}/api/v4`;\n  }\n\n  const instance = GITLAB_INSTANCES[instanceType];\n  return `${instance.baseUrl}/api/v4`;\n}\n\n// 获取通用请求头\nasync function getCommonHeaders(): Promise<any> {\n  const store = await Store.load('store.json');\n  const accessToken = await store.get<string>('gitlabAccessToken');\n\n  if (!accessToken) {\n    throw new Error('GitLab Access Token 未配置');\n  }\n\n  const headers = {\n    \"Content-Type\": 'application/json;charset=iso-8859-1',\n    \"PRIVATE-TOKEN\": accessToken,\n  };\n\n  return headers;\n}\n\n// 获取代理配置\nasync function getProxyConfig(): Promise<Proxy | undefined> {\n  const store = await Store.load('store.json');\n  const proxyUrl = await store.get<string>('proxy');\n  return proxyUrl ? { all: proxyUrl } : undefined;\n}\n\n/**\n * 上传文件到 Gitlab 项目\n * @param params 上传参数\n */\nexport async function uploadFile({\n  file,\n  filename,\n  sha,\n  message,\n  repo,\n  path\n}: {\n  file: string;\n  filename?: string;\n  sha?: string;\n  message?: string;\n  repo: string;\n  path?: string;\n}) {\n  console.log('[gitlab uploadFile] file length:', file.length, 'filename:', filename, 'path:', path, 'sha:', sha)\n  try {\n    const store = await Store.load('store.json');\n    const gitlabUsername = await store.get<string>('gitlabUsername');\n    const projectId = await store.get<string>(`gitlab_${repo}_project_id`);\n    \n    if (!gitlabUsername || !projectId) {\n      throw new Error('Gitlab 用户名或项目 ID 未配置');\n    }\n\n    const id = uuid();\n    let _filename = filename || id;\n    // 将空格转换成下划线\n    _filename = _filename.replace(/\\s/g, '_');\n\n    // path 是完整路径（如 notes/test.md），需要分离出目录和文件名\n    // 参考 Gitea 的处理方式\n    const _path = path ? `/${path}` : '';\n    // 先去掉开头的 /，再分割，然后去掉最后一个（文件名），最后重新组合\n    const pathParts = _path.split('/').filter(p => p); // 去掉空字符串\n    const encodedPath = pathParts.slice(0, -1).map(p => encodeURIComponent(p.replace(/\\s/g, '_'))).join('/');\n    const normalizedPath = pathParts.length > 1 ? `${encodedPath}/${_filename}` : (pathParts.length === 1 ? `${pathParts[0]}/${_filename}` : _filename);\n\n    console.log('[gitlab uploadFile] path:', path, '_path:', _path, 'pathParts:', pathParts, 'normalizedPath:', normalizedPath)\n\n    // 将内容转换为 Base64（GitLab API 要求）\n    const base64Content = Buffer.from(file, 'utf-8').toString('base64')\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    const requestBody = {\n      branch: 'main',\n      content: base64Content,\n      commit_message: message || `Upload ${filename || id}`,\n      encoding: 'base64'\n    };\n\n    // 如果是更新文件，需要添加 last_commit_id\n    if (sha) {\n      // 获取文件的最新提交 ID\n      const commitsUrl = `${baseUrl}/projects/${projectId}/repository/commits?path=${encodeURIComponent(path?.replace(/\\s/g, '_') || '')}`;\n      const commitsResponse = await fetch(commitsUrl, {\n        method: 'GET',\n        headers,\n        proxy\n      });\n\n      if (commitsResponse.ok) {\n        const commits = await commitsResponse.json() as GitlabCommit[];\n        if (commits.length > 0) {\n          (requestBody as any).last_commit_id = commits[0].id;\n        }\n      }\n    }\n\n    const url = `${baseUrl}/projects/${projectId}/repository/files/${normalizedPath}`;\n\n    // 首先尝试使用 Commits API 创建文件（会自动创建目录）\n    // GitLab Commits API 可以通过一次 commit 创建多个文件，包括父目录\n    const commitsApiUrl = `${baseUrl}/projects/${projectId}/repository/commits`;\n\n    const commitActions = [{\n      action: sha ? 'update' : 'create',\n      file_path: normalizedPath,\n      content: base64Content,\n      encoding: 'base64'\n    }];\n\n    const commitBody = {\n      branch: 'main',\n      commit_message: message || `Upload ${filename || id}`,\n      actions: commitActions\n    };\n\n    console.log('[gitlab uploadFile] Trying Commits API to create file, url:', commitsApiUrl)\n\n    const commitResponse = await fetch(commitsApiUrl, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify(commitBody),\n      proxy\n    });\n\n    console.log('[gitlab uploadFile] Commits API status:', commitResponse.status)\n\n    if (commitResponse.status >= 200 && commitResponse.status < 300) {\n      const data = await commitResponse.json();\n      console.log('[gitlab uploadFile] Commits API success:', data)\n      return { data } as GitlabResponse<any>;\n    }\n\n    // 如果是 400 错误，可能文件已存在，尝试用 PUT 更新\n    if (commitResponse.status === 400) {\n      const commitErrorData = await commitResponse.json();\n      console.log('[gitlab uploadFile] Commits API error:', commitErrorData)\n\n      // 检查是否是文件已存在的错误\n      if (commitErrorData.error && commitErrorData.error.includes('already exists')) {\n        // 获取当前文件的 SHA\n        const fileUrl = `${baseUrl}/projects/${projectId}/repository/files/${normalizedPath}?ref=main`;\n        const fileResponse = await fetch(fileUrl, {\n          method: 'GET',\n          headers,\n          proxy\n        });\n\n        let fileSha = '';\n        if (fileResponse.ok) {\n          const fileData = await fileResponse.json();\n          fileSha = fileData.blob_id || fileData.sha;\n        }\n\n        // 使用 PUT 更新文件\n        const putBody = {\n          branch: 'main',\n          content: base64Content,\n          commit_message: message || `Update ${filename || id}`,\n          encoding: 'base64',\n          sha: fileSha\n        };\n\n        const putResponse = await fetch(url, {\n          method: 'PUT',\n          headers,\n          body: JSON.stringify(putBody),\n          proxy\n        });\n\n        console.log('[gitlab uploadFile] PUT status:', putResponse.status)\n\n        if (putResponse.status >= 200 && putResponse.status < 300) {\n          const data = await putResponse.json();\n          return { data } as GitlabResponse<any>;\n        }\n\n        const putErrorData = await putResponse.json();\n        throw {\n          status: putResponse.status,\n          message: putErrorData.message || '更新文件失败'\n        } as GitlabError;\n      }\n\n      throw {\n        status: commitResponse.status,\n        message: commitErrorData.error || '同步失败'\n      } as GitlabError;\n    }\n\n    // 其他错误\n    const commitErrorData = await commitResponse.json();\n    console.log('[gitlab uploadFile] Commits API error:', commitErrorData)\n    throw {\n      status: commitResponse.status,\n      message: commitErrorData.error || commitErrorData.message || '同步失败'\n    } as GitlabError;\n\n  } catch (error) {\n    toast({\n      title: '同步失败',\n      description: (error as GitlabError).message || '上传文件时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 获取 Gitlab 项目文件列表或单个文件信息\n * @param params 查询参数\n */\nexport async function getFiles({ path, repo }: { path: string; repo: string }) {\n  console.log('[gitlab getFiles] path:', path, 'repo:', repo)\n  try {\n    const store = await Store.load('store.json');\n    const projectId = await store.get<string>(`gitlab_${repo}_project_id`);\n    console.log('[gitlab getFiles] projectId:', projectId)\n\n    if (!projectId) {\n      throw new Error('项目 ID 未配置');\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    console.log('[gitlab getFiles] baseUrl:', baseUrl)\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 先尝试获取单个文件信息\n    const fileUrl = `${baseUrl}/projects/${projectId}/repository/files/${encodeURIComponent(path)}?ref=main`;\n    console.log('[gitlab getFiles] fileUrl:', fileUrl)\n\n    try {\n      const fileResponse = await fetch(fileUrl, {\n        method: 'GET',\n        headers,\n        proxy\n      });\n      console.log('[gitlab getFiles] fileResponse status:', fileResponse.status)\n\n      if (fileResponse.status >= 200 && fileResponse.status < 300) {\n        const fileData = await fileResponse.json();\n        // 返回单个文件对象，包含 sha (使用 blob_id 作为 sha)\n        return {\n          name: fileData.file_name,\n          path: fileData.file_path,\n          sha: fileData.blob_id,\n          size: fileData.size,\n        };\n      }\n    } catch (e) {\n      console.log('[gitlab getFiles] file fetch error:', e)\n      // 如果获取单个文件失败，继续尝试获取目录列表\n    }\n\n    // 如果不是单个文件，尝试获取目录列表\n    const url = `${baseUrl}/projects/${projectId}/repository/tree?path=${path}`;\n    console.log('[gitlab getFiles] treeUrl:', url)\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n    console.log('[gitlab getFiles] treeResponse status:', response.status)\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GitlabRepositoryFile[];\n      console.log('[gitlab getFiles] tree data:', data)\n      return data.map(item => {\n        return {\n          name: item.name,\n          path: item.path,\n          type: item.type === 'tree' ? 'dir' : 'file',\n          sha: item.id,\n        }\n      })\n    }\n\n    // 文件或目录不存在，返回 null\n    if (response.status === 404) {\n      return null\n    }\n\n    // 401 或其他客户端错误，抛出错误\n    if (response.status >= 400 && response.status < 500) {\n      const errorData = await response.json().catch(() => ({}));\n      throw {\n        status: response.status,\n        message: errorData.message || `获取文件列表失败: ${response.status}`\n      } as GitlabError;\n    }\n\n    return null;\n\n  } catch (error) {\n    // 重新抛出已处理的错误，静默处理其他错误\n    if ((error as GitlabError).status) {\n      throw error;\n    }\n    // 静默处理错误，不显示 toast，因为这可能只是文件不存在\n    return null;\n  }\n}\n\n/**\n * 删除 Gitlab 项目文件\n * @param params 删除参数\n */\nexport async function deleteFile({ path, repo }: { path: string; sha?: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const projectId = await store.get<string>(`gitlab_${repo}_project_id`);\n    \n    if (!projectId) {\n      throw new Error('项目 ID 未配置');\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 获取文件的最新提交 ID，对 path 进行编码\n    const encodedPath = encodeURIComponent(path);\n    const commitsUrl = `${baseUrl}/projects/${projectId}/repository/commits?path=${encodedPath}&per_page=1`;\n    const commitsResponse = await fetch(commitsUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    let lastCommitId = '';\n    if (commitsResponse.ok) {\n      const commits = await commitsResponse.json() as GitlabCommit[];\n      if (commits.length > 0) {\n        lastCommitId = commits[0].id;\n      }\n    }\n\n    const url = `${baseUrl}/projects/${projectId}/repository/files/${encodeURIComponent(path)}`;\n    \n    const response = await fetch(url, {\n      method: 'DELETE',\n      headers,\n      body: JSON.stringify({\n        branch: 'main',\n        commit_message: `Delete ${path}`,\n        last_commit_id: lastCommitId\n      }),\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      return true\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '删除文件失败'\n    } as GitlabError;\n\n  } catch (error) {\n    toast({\n      title: '删除文件失败',\n      description: (error as GitlabError).message || '删除文件时发生错误',\n      variant: 'destructive',\n    });\n    return null; // 确保在错误情况下也有返回值\n  }\n}\n\n/**\n * 获取文件提交历史\n * @param params 查询参数\n */\nexport async function getFileCommits({ path, repo }: { path: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const projectId = await store.get<string>(`gitlab_${repo}_project_id`);\n    \n    if (!projectId) {\n      return false;\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 对 path 进行编码，避免特殊字符导致 404\n    const encodedPath = encodeURIComponent(path);\n    const url = `${baseUrl}/projects/${projectId}/repository/commits?path=${encodedPath}&per_page=100`;\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const data = await response.json() as GitlabCommit[];\n      return { data } as GitlabResponse<GitlabCommit[]>;\n    }\n\n    // 404 或其他错误，静默返回 false（文件没有提交历史）\n    return false;\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  } catch (error) {\n    // 静默处理错误，不显示 toast\n    return false;\n  }\n}\n\n/**\n * 获取特定 commit 的文件内容\n * @param params 查询参数\n */\nexport async function getFileContent({ path, ref, repo }: { path: string; ref: string; repo: string }) {\n  try {\n    const store = await Store.load('store.json');\n    const projectId = await store.get<string>(`gitlab_${repo}_project_id`);\n    \n    if (!projectId) {\n      throw new Error('项目 ID 未配置');\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 使用 Gitlab API 获取特定 commit 的文件内容\n    const url = `${baseUrl}/projects/${projectId}/repository/files/${path.replace(/\\//g, '%2F')}/raw?ref=${ref}`;\n\n    const response = await encodeFetch(url, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const content = await response.text();\n      // 将内容转换为 base64 编码，保持与 GitHub/Gitee 接口一致\n      const base64Content = btoa(unescape(encodeURIComponent(content)));\n      return {\n        content: base64Content,\n        encoding: 'base64'\n      };\n    }\n\n    if (response.status >= 400 && response.status < 500) {\n      return {\n        content: '',\n        encoding: 'base64'\n      }\n    }\n\n    const errorData = await response.text();\n    throw {\n      status: response.status,\n      message: errorData || '获取文件内容失败'\n    } as GitlabError;\n\n  } catch (error) {\n    toast({\n      title: '获取文件内容失败',\n      description: (error as GitlabError).message || '获取文件内容时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 获取 Gitlab 用户信息\n * @param token 可选的访问令牌\n */\nexport async function getUserInfo(token?: string): Promise<GitlabUserInfo> {\n  try {\n    const store = await Store.load('store.json');\n    const accessToken = token || await store.get<string>('gitlabAccessToken');\n    \n    if (!accessToken) {\n      throw new Error('访问令牌未配置');\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const proxy = await getProxyConfig();\n\n    const headers = new Headers();\n    headers.append('Authorization', `Bearer ${accessToken}`);\n    headers.append('Content-Type', 'application/json');\n\n    const response = await fetch(`${baseUrl}/user`, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const userInfo = await response.json() as GitlabUserInfo;\n      \n      // 保存用户名到存储\n      await store.set('gitlabUsername', userInfo.username);\n      await store.save();\n      \n      return userInfo;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '获取用户信息失败'\n    } as GitlabError;\n\n  } catch (error) {\n    toast({\n      title: '获取用户信息失败',\n      description: (error as GitlabError).message || '获取用户信息时发生错误',\n      variant: 'destructive',\n    });\n    throw error;\n  }\n}\n\n/**\n * 检查同步项目状态\n * @param name 项目名称\n */\nexport async function checkSyncProjectState(name: string): Promise<GitlabProjectInfo | null> {\n  try {\n    const store = await Store.load('store.json');\n    const gitlabUsername = await store.get<string>('gitlabUsername');\n    \n    if (!gitlabUsername) {\n      throw new Error('用户名未配置');\n    }\n\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    // 搜索项目\n    const searchUrl = `${baseUrl}/projects?search=${name}&owned=true&per_page=10`;\n    \n    const response = await fetch(searchUrl, {\n      method: 'GET',\n      headers,\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const projects = await response.json() as GitlabProjectInfo[];\n      \n      // 查找匹配的项目\n      const project = projects.find(p => p.name === name && p.namespace.path === gitlabUsername);\n      \n      if (project) {\n        // 保存项目 ID\n        await store.set(`gitlab_${name}_project_id`, project.id.toString());\n        await store.save();\n      }\n      \n      return project || null;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '检查项目状态失败'\n    } as GitlabError;\n\n  } catch (error) {\n    throw error;\n  }\n}\n\n/**\n * 创建同步项目\n * @param name 项目名称\n * @param isPrivate 是否私有项目\n */\nexport async function createSyncProject(name: string, isPrivate: boolean = true): Promise<GitlabProjectInfo | null> {\n  try {\n    const baseUrl = await getGitlabApiBaseUrl();\n    const headers = await getCommonHeaders();\n    const proxy = await getProxyConfig();\n\n    const requestBody = {\n      name: name,\n      path: name,\n      description: `note-gen 同步项目 - ${name}`,\n      visibility: isPrivate ? 'private' : 'public',\n      initialize_with_readme: true,\n      default_branch: 'main'\n    };\n\n    const response = await fetch(`${baseUrl}/projects`, {\n      method: 'POST',\n      headers,\n      body: JSON.stringify(requestBody),\n      proxy\n    });\n\n    if (response.status >= 200 && response.status < 300) {\n      const project = await response.json() as GitlabProjectInfo;\n      \n      // 保存项目 ID\n      const store = await Store.load('store.json');\n      await store.set(`gitlab_${name}_project_id`, project.id.toString());\n      await store.save();\n      \n      return project;\n    }\n\n    const errorData = await response.json();\n    throw {\n      status: response.status,\n      message: errorData.message || '创建项目失败'\n    } as GitlabError;\n\n  } catch (error) {\n    toast({\n      title: '创建项目失败',\n      description: (error as GitlabError).message || '创建项目时发生错误',\n      variant: 'destructive',\n    });\n    return null;\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/gitlab.types.ts",
    "content": "// Gitlab 实例类型枚举\nexport enum GitlabInstanceType {\n  OFFICIAL = 'gitlab.com',      // 官方国际版\n  JIHULAB = 'gitlab.cn',        // 中国极狐版\n  SELF_HOSTED = 'self-hosted'   // 自建实例\n}\n\n// Gitlab 实例配置\nexport interface GitlabInstanceConfig {\n  type: GitlabInstanceType;\n  baseUrl: string;\n  name: string;\n  description: string;\n}\n\n// 预定义的 Gitlab 实例配置\nexport const GITLAB_INSTANCES: Record<GitlabInstanceType, GitlabInstanceConfig> = {\n  [GitlabInstanceType.OFFICIAL]: {\n    type: GitlabInstanceType.OFFICIAL,\n    baseUrl: 'https://gitlab.com',\n    name: 'GitLab.com',\n    description: '官方国际版 GitLab'\n  },\n  [GitlabInstanceType.JIHULAB]: {\n    type: GitlabInstanceType.JIHULAB,\n    baseUrl: 'https://jihulab.com',\n    name: '极狐GitLab',\n    description: '中国版 GitLab'\n  },\n  [GitlabInstanceType.SELF_HOSTED]: {\n    type: GitlabInstanceType.SELF_HOSTED,\n    baseUrl: '',\n    name: '自建实例',\n    description: '自建 GitLab 服务器'\n  }\n};\n\n// Gitlab 错误类型\nexport interface GitlabError {\n  status: number;\n  message: string;\n}\n\n// Gitlab 用户信息类型\nexport interface GitlabUserInfo {\n  id: number;\n  username: string;\n  name: string;\n  state: string;\n  avatar_url: string;\n  web_url: string;\n  created_at: string;\n  bio: string;\n  location: string;\n  public_email: string;\n  skype: string;\n  linkedin: string;\n  twitter: string;\n  website_url: string;\n  organization: string;\n}\n\n// Gitlab 项目信息类型\nexport interface GitlabProjectInfo {\n  id: number;\n  name: string;\n  name_with_namespace: string;\n  path: string;\n  path_with_namespace: string;\n  created_at: string;\n  updated_at: string;\n  default_branch: string;\n  description: string;\n  web_url: string;\n  avatar_url: string;\n  star_count: number;\n  forks_count: number;\n  last_activity_at: string;\n  namespace: {\n    id: number;\n    name: string;\n    path: string;\n    kind: string;\n    full_path: string;\n    avatar_url: string;\n    web_url: string;\n  };\n  visibility: 'private' | 'internal' | 'public';\n  issues_enabled: boolean;\n  merge_requests_enabled: boolean;\n  wiki_enabled: boolean;\n  jobs_enabled: boolean;\n  snippets_enabled: boolean;\n  container_registry_enabled: boolean;\n  service_desk_enabled: boolean;\n  can_create_merge_request_in: boolean;\n  issues_access_level: string;\n  repository_access_level: string;\n  merge_requests_access_level: string;\n  forking_access_level: string;\n  wiki_access_level: string;\n  builds_access_level: string;\n  snippets_access_level: string;\n  pages_access_level: string;\n  analytics_access_level: string;\n  container_registry_access_level: string;\n  security_and_compliance_access_level: string;\n  releases_access_level: string;\n  environments_access_level: string;\n  feature_flags_access_level: string;\n  infrastructure_access_level: string;\n  monitor_access_level: string;\n  model_experiments_access_level: string;\n  model_registry_access_level: string;\n}\n\n// Gitlab 文件信息类型\nexport interface GitlabFile {\n  file_name: string;\n  file_path: string;\n  size: number;\n  encoding: string;\n  content_sha256: string;\n  ref: string;\n  blob_id: string;\n  commit_id: string;\n  last_commit_id: string;\n  content?: string; // 文件内容，base64 编码\n}\n\n// Gitlab 仓库文件列表项类型\nexport interface GitlabRepositoryFile {\n  id: string;\n  name: string;\n  type: 'tree' | 'blob';\n  path: string;\n  mode: string;\n}\n\n// Gitlab 提交信息类型\nexport interface GitlabCommit {\n  id: string;\n  short_id: string;\n  created_at: string;\n  parent_ids: string[];\n  title: string;\n  message: string;\n  author_name: string;\n  author_email: string;\n  authored_date: string;\n  committer_name: string;\n  committer_email: string;\n  committed_date: string;\n  trailers: Record<string, string>;\n  web_url: string;\n}\n\n// Gitlab API 响应类型\nexport type GitlabResponse<T> = {\n  data: T;\n  status?: number;\n  headers?: Record<string, string>;\n}\n\n// 同步状态枚举（复用现有的）\nexport { SyncStateEnum } from './github.types';\n\n// 仓库名称枚举（复用现有的）\nexport { RepoNames } from './github.types';\n"
  },
  {
    "path": "src/lib/sync/remote-file.ts",
    "content": "function normalizeSegment(segment: string) {\n  return segment.replace(/\\s/g, '_')\n}\n\nfunction encodePath(path: string) {\n  return path\n    .split('/')\n    .filter(Boolean)\n    .map(segment => encodeURIComponent(normalizeSegment(segment)))\n    .join('/')\n}\n\nexport function buildRepoContentPath({\n  path,\n  filename,\n}: {\n  path?: string\n  filename?: string\n}) {\n  const normalizedPath = path?.replace(/^\\/+|\\/+$/g, '') || ''\n  const normalizedFilename = filename ? normalizeSegment(filename) : ''\n\n  if (!normalizedPath) {\n    return normalizedFilename ? encodePath(normalizedFilename) : ''\n  }\n\n  if (!normalizedFilename) {\n    return encodePath(normalizedPath)\n  }\n\n  const segments = normalizedPath.split('/').filter(Boolean).map(normalizeSegment)\n  if (segments[segments.length - 1] !== normalizedFilename) {\n    segments.push(normalizedFilename)\n  }\n\n  return segments.map(encodeURIComponent).join('/')\n}\n\nexport function buildRepoContentsEndpoint(path?: string) {\n  if (!path) {\n    return '/contents'\n  }\n\n  return `/contents/${path.replace(/^\\/+/, '')}`\n}\n\ntype RemoteDirectoryEntry = {\n  type?: string\n  name?: string\n  path?: string\n  sha?: string\n}\n\nexport function pickNestedFileEntry(entries: RemoteDirectoryEntry[], requestedPath: string) {\n  const files = entries.filter(entry => entry.type === 'file' && typeof entry.path === 'string')\n  if (files.length === 0) {\n    return null\n  }\n\n  const expectedName = requestedPath.split('/').filter(Boolean).pop()?.replace(/\\s/g, '_')\n  if (expectedName) {\n    const namedMatch = files.find(entry => entry.name === expectedName)\n    if (namedMatch) {\n      return namedMatch\n    }\n  }\n\n  return files.length === 1 ? files[0] : null\n}\n\nexport function getRemoteFileContent(file: unknown, path: string) {\n  if (!file) {\n    throw new Error(`远程文件不存在: ${path}`)\n  }\n\n  if (Array.isArray(file)) {\n    throw new Error(`远程路径指向的是目录，不是文件: ${path}`)\n  }\n\n  const content = (file as { content?: unknown }).content\n  if (typeof content !== 'string') {\n    throw new Error(`远程文件内容格式无效: ${path}`)\n  }\n\n  return content\n}\n\nexport function decodeBase64ToString(content: unknown) {\n  if (typeof content !== 'string') {\n    throw new Error('远程文件内容不是有效的 Base64 字符串')\n  }\n\n  const normalized = content.replace(/\\s+/g, '')\n  if (!normalized) {\n    return ''\n  }\n\n  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 === 1) {\n    throw new Error('远程文件内容不是有效的 Base64 字符串')\n  }\n\n  return Buffer.from(normalized, 'base64').toString('utf-8')\n}\n"
  },
  {
    "path": "src/lib/sync/repo-utils.ts",
    "content": "import { RepoNames } from './github.types'\nimport { Store } from '@tauri-apps/plugin-store'\n\n/**\n * 获取实际使用的仓库名称\n * @param type 仓库类型：'sync' | 'image'\n * @param platform 平台：'github' | 'gitee' | 'gitlab' | 'gitea'\n * @returns 实际使用的仓库名称\n */\nexport async function getActualRepoName(\n  type: 'sync' | 'image',\n  platform: 'github' | 'gitee' | 'gitlab' | 'gitea'\n): Promise<string> {\n  const store = await Store.load('store.json')\n  \n  // 根据类型和平台获取自定义仓库名\n  let customRepoName = ''\n  \n  if (type === 'sync') {\n    switch (platform) {\n      case 'github':\n        customRepoName = await store.get<string>('githubCustomSyncRepo') || ''\n        break\n      case 'gitee':\n        customRepoName = await store.get<string>('giteeCustomSyncRepo') || ''\n        break\n      case 'gitlab':\n        customRepoName = await store.get<string>('gitlabCustomSyncRepo') || ''\n        break\n      case 'gitea':\n        customRepoName = await store.get<string>('giteaCustomSyncRepo') || ''\n        break\n    }\n  } else if (type === 'image' && platform === 'github') {\n    customRepoName = await store.get<string>('githubCustomImageRepo') || ''\n  }\n  \n  // 如果有自定义仓库名且不为空，使用自定义名称，否则使用默认名称\n  if (customRepoName.trim()) {\n    return customRepoName.trim()\n  }\n  \n  // 返回默认仓库名\n  return type === 'sync' ? RepoNames.sync : RepoNames.image\n}\n\n/**\n * 获取同步仓库名称\n * @param platform 平台：'github' | 'gitee' | 'gitlab' | 'gitea'\n * @returns 同步仓库名称\n */\nexport async function getSyncRepoName(platform: 'github' | 'gitee' | 'gitlab' | 'gitea'): Promise<string> {\n  return getActualRepoName('sync', platform)\n}\n\n/**\n * 获取图床仓库名称（仅支持GitHub）\n * @returns GitHub图床仓库名称\n */\nexport async function getImageRepoName(): Promise<string> {\n  return getActualRepoName('image', 'github')\n}\n"
  },
  {
    "path": "src/lib/sync/s3.ts",
    "content": "import { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { S3Config } from '@/types/sync'\n\n/**\n * S3 同步核心模块\n * 支持阿里云 OSS、AWS S3、MinIO 等 S3 兼容服务\n */\n\n\n// 生成 AWS 签名 V4 (使用 Web Crypto API)\nasync function generateSignature(\n  method: string,\n  url: string,\n  headers: Record<string, string>,\n  payload: BufferSource,\n  config: S3Config\n) {\n  const algorithm = 'AWS4-HMAC-SHA256'\n  const date = new Date()\n  const dateStamp = date.toISOString().slice(0, 10).replace(/-/g, '')\n  const amzDate = date.toISOString().replace(/[:\\-]|\\.\\d{3}/g, '')\n\n  // 必须将 x-amz-date 加入 headers 参与签名\n  headers['x-amz-date'] = amzDate\n\n  // 创建规范请求\n  // 必须对路径进行 URI 编码，但要保留斜杠\n  const urlObj = new URL(url)\n  const canonicalUri = urlObj.pathname\n\n  // AWS V4 签名要求查询字符串必须按字母顺序排列并正确编码\n  const canonicalQuerystring = Array.from(urlObj.searchParams.entries())\n    .sort(([a], [b]) => a.localeCompare(b))\n    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)\n    .join('&')\n\n  // AWS V4 签名要求 Headers 的 Key 必须全部转为小写\n  const canonicalHeaders = Object.keys(headers)\n    .sort()\n    .map(key => `${key.toLowerCase()}:${headers[key].trim()}\\n`)\n    .join('')\n\n  const signedHeaders = Object.keys(headers)\n    .sort()\n    .map(key => key.toLowerCase())\n    .join(';')\n\n  // 使用 Web Crypto API 计算 SHA256\n  const payloadHash = await crypto.subtle.digest('SHA-256', payload)\n  const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('')\n\n  const canonicalRequest = [\n    method,\n    canonicalUri,\n    canonicalQuerystring,\n    canonicalHeaders,\n    signedHeaders,\n    payloadHashHex\n  ].join('\\n')\n\n  // 创建字符串以供签名\n  const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`\n\n  const stringToSign = [\n    algorithm,\n    amzDate,\n    credentialScope,\n    await sha256Hex(canonicalRequest)\n  ].join('\\n')\n\n  // 计算签名\n  const signingKey = await getSignatureKey(config.secretAccessKey, dateStamp, config.region, 's3')\n  const signature = await hmacSha256Hex(signingKey, stringToSign)\n\n  return {\n    authorization: `${algorithm} Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n    amzDate,\n    payloadHashHex\n  }\n}\n\n// Web Crypto API 辅助函数\nasync function sha256Hex(data: string): Promise<string> {\n  const encoder = new TextEncoder()\n  const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data))\n  return Array.from(new Uint8Array(hash))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('')\n}\n\nasync function hmacSha256(key: CryptoKey, data: string): Promise<ArrayBuffer> {\n  const encoder = new TextEncoder()\n  return await crypto.subtle.sign('HMAC', key, encoder.encode(data))\n}\n\nasync function hmacSha256Hex(key: CryptoKey, data: string): Promise<string> {\n  const signature = await hmacSha256(key, data)\n  return Array.from(new Uint8Array(signature))\n    .map(b => b.toString(16).padStart(2, '0'))\n    .join('')\n}\n\nasync function getSignatureKey(\n  key: string,\n  dateStamp: string,\n  regionName: string,\n  serviceName: string\n): Promise<CryptoKey> {\n  const encoder = new TextEncoder()\n\n  // 导入初始密钥\n  const kSecret = await crypto.subtle.importKey(\n    'raw',\n    encoder.encode('AWS4' + key),\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n\n  // kDate = HMAC(\"AWS4\" + kSecret, Date)\n  const kDate = await crypto.subtle.sign('HMAC', kSecret, encoder.encode(dateStamp))\n\n  const kDateKey = await crypto.subtle.importKey(\n    'raw',\n    kDate,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n\n  // kRegion = HMAC(kDate, Region)\n  const kRegion = await crypto.subtle.sign('HMAC', kDateKey, encoder.encode(regionName))\n  const kRegionKey = await crypto.subtle.importKey(\n    'raw',\n    kRegion,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n\n  // kService = HMAC(kRegion, Service)\n  const kService = await crypto.subtle.sign('HMAC', kRegionKey, encoder.encode(serviceName))\n  const kServiceKey = await crypto.subtle.importKey(\n    'raw',\n    kService,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n\n  // kSigning = HMAC(kService, \"aws4_request\")\n  const kSigning = await crypto.subtle.sign('HMAC', kServiceKey, encoder.encode('aws4_request'))\n  return await crypto.subtle.importKey(\n    'raw',\n    kSigning,\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n}\n\n/**\n * 构建 S3 URL\n * 支持 Virtual Hosted Style 和 Path Style\n */\nfunction buildS3Url(config: S3Config, key: string): string {\n  const endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim()\n  const bucket = config.bucket.trim()\n\n  // 移除 endpoint 末尾的斜杠\n  const cleanEndpoint = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint\n\n  // 处理 pathPrefix，移除末尾的斜杠以防止双斜杠问题\n  const prefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n  const fullKey = prefix ? `${prefix}/${key}` : key\n\n  let url = ''\n\n  // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务\n  const isAliyun = cleanEndpoint.includes('aliyuncs.com')\n  const isAWS = cleanEndpoint.includes('amazonaws.com')\n  const isCloudflareR2 = cleanEndpoint.includes('cloudflarestorage.com')\n\n  // Cloudflare R2 需要使用 Path Style，不是 Virtual Hosted Style\n  if (isCloudflareR2) {\n    // 使用 Path Style: https://endpoint/bucket/key\n    url = `${cleanEndpoint}/${bucket}/${fullKey}`\n  } else if (isAliyun || isAWS) {\n    // 使用 Virtual Hosted Style: https://bucket.endpoint/key\n    try {\n      const urlObj = new URL(cleanEndpoint)\n      urlObj.hostname = `${bucket}.${urlObj.hostname}`\n      url = `${urlObj.toString()}/${fullKey}`\n      // 处理可能的双斜杠\n      url = url.replace(/([^:]\\/)\\/+/g, '$1')\n    } catch {\n      console.warn('[S3 Sync] Failed to switch to Virtual Hosted Style, using Path Style')\n      url = `${cleanEndpoint}/${bucket}/${fullKey}`\n    }\n  } else {\n    // MinIO 等使用 Path Style\n    url = `${cleanEndpoint}/${bucket}/${fullKey}`\n  }\n\n  return url\n}\n\n/**\n * 构建 S3 基础 URL（不含 key）\n */\nfunction buildS3BaseUrl(config: S3Config): string {\n  const endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim()\n  const bucket = config.bucket.trim()\n\n  // 移除 endpoint 末尾的斜杠\n  const cleanEndpoint = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint\n\n  // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化\n  const isAliyun = cleanEndpoint.includes('aliyuncs.com')\n  const isAWS = cleanEndpoint.includes('amazonaws.com')\n\n  if (isAliyun || isAWS) {\n    try {\n      const urlObj = new URL(cleanEndpoint)\n      urlObj.hostname = `${bucket}.${urlObj.hostname}`\n      return urlObj.toString().replace(/\\/+$/, '')\n    } catch {\n      console.warn('[S3 Sync] Failed to switch to Virtual Hosted Style, using Path Style')\n      return `${cleanEndpoint}/${bucket}`\n    }\n  }\n\n  return `${cleanEndpoint}/${bucket}`\n}\n\n/**\n * 测试 S3 连接\n */\nexport async function testS3Connection(config: S3Config, proxy?: Proxy): Promise<boolean> {\n  try {\n    const baseUrl = buildS3BaseUrl(config)\n\n    const emptyPayload = new ArrayBuffer(0)\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload)\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('')\n\n    const headers: Record<string, string> = {\n      Host: new URL(baseUrl).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    }\n\n    // 使用 GET 请求代替 HEAD，以便在出错时能获取具体的 XML 错误信息\n    const method = 'GET'\n    const { authorization, amzDate } = await generateSignature(method, baseUrl, headers, emptyPayload, config)\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(baseUrl, {\n      method: method,\n      headers: requestHeaders,\n      proxy\n    })\n\n    if (response.status === 200) {\n      return true\n    }\n\n    // 如果 GET (ListObjects) 失败（可能是只有写权限），尝试 PUT 一个测试文件\n    if (response.status === 403) {\n      console.warn('ListObjects (GET) failed with 403, trying PutObject to verify write permission...')\n\n      const testKey = '.connection-test'\n      const testUrl = buildS3Url(config, testKey)\n      const testContent = new TextEncoder().encode('test')\n\n      const putHeaders = {\n        Host: new URL(testUrl).host,\n        'Content-Type': 'text/plain',\n        'Content-Length': testContent.byteLength.toString()\n      }\n\n      const { authorization: authPut, amzDate: datePut, payloadHashHex: hashPut } =\n        await generateSignature('PUT', testUrl, putHeaders, testContent, config)\n\n      const requestPutHeaders = new Headers()\n      requestPutHeaders.append('Authorization', authPut)\n      requestPutHeaders.append('X-Amz-Date', datePut)\n      requestPutHeaders.append('Content-Type', 'text/plain')\n      requestPutHeaders.append('X-Amz-Content-Sha256', hashPut)\n\n      const putResponse = await fetch(testUrl, {\n        method: 'PUT',\n        headers: requestPutHeaders,\n        body: testContent,\n        proxy\n      })\n\n      if (putResponse.status === 200 || putResponse.status === 204) {\n        // 清理测试文件\n        try {\n          const deleteHeaders = {\n            Host: new URL(testUrl).host\n          }\n          const { authorization: authDel, amzDate: dateDel, payloadHashHex: hashDel } =\n            await generateSignature('DELETE', testUrl, deleteHeaders, emptyPayload, config)\n\n          const requestDelHeaders = new Headers()\n          requestDelHeaders.append('Authorization', authDel)\n          requestDelHeaders.append('X-Amz-Date', dateDel)\n          requestDelHeaders.append('X-Amz-Content-Sha256', hashDel)\n\n          await fetch(testUrl, {\n            method: 'DELETE',\n            headers: requestDelHeaders,\n            proxy\n          })\n        } catch {\n          // 忽略清理错误\n        }\n        return true\n      } else {\n        const putErrorText = await putResponse.text()\n        console.error('PutObject also failed:', putResponse.status, putErrorText)\n      }\n    }\n\n    const errorText = await response.text()\n    console.warn('S3 Check Failed:', {\n      status: response.status,\n      statusText: response.statusText,\n      url: baseUrl,\n      headers: Object.fromEntries(response.headers.entries()),\n      errorBody: errorText || '(empty body)'\n    })\n\n    return false\n  } catch (error) {\n    console.error('S3 connection test failed:', error)\n\n    // 尝试提取更有用的错误信息\n    const errorMessage = (error as Error).message || String(error)\n    if (errorMessage.includes('error sending request')) {\n      console.warn(\n        'Network Error Details: Please check your Endpoint, Region, and Proxy settings. URL might be malformed.'\n      )\n    }\n\n    return false\n  }\n}\n\n/**\n * 上传文件到 S3（类似推送）\n */\nexport async function s3Upload(\n  config: S3Config,\n  key: string,\n  content: string,\n  proxy?: Proxy\n): Promise<{ etag: string } | null> {\n  try {\n    const url = buildS3Url(config, key)\n    const contentBytes = new TextEncoder().encode(content)\n\n    const headers = {\n      Host: new URL(url).host,\n      'Content-Type': 'text/markdown; charset=utf-8',\n      'Content-Length': contentBytes.byteLength.toString()\n    }\n\n    const { authorization, amzDate, payloadHashHex } = await generateSignature(\n      'PUT',\n      url,\n      headers,\n      contentBytes,\n      config\n    )\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('Content-Type', 'text/markdown; charset=utf-8')\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(url, {\n      method: 'PUT',\n      headers: requestHeaders,\n      body: contentBytes,\n      proxy\n    })\n\n    if (response.status === 200 || response.status === 204) {\n      // 获取 ETag\n      const etag = response.headers.get('ETag') || ''\n      return { etag }\n    } else {\n      const errorText = await response.text()\n      console.error('S3 Upload failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('S3 Upload error:', error)\n    return null\n  }\n}\n\n/**\n * 从 S3 下载文件（类似拉取）\n */\nexport async function s3Download(\n  config: S3Config,\n  key: string,\n  proxy?: Proxy\n): Promise<{ content: string; etag: string; lastModified: string } | null> {\n  try {\n    const url = buildS3Url(config, key)\n\n    const emptyPayload = new ArrayBuffer(0)\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload)\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('')\n\n    const headers: Record<string, string> = {\n      Host: new URL(url).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    }\n\n    const { authorization, amzDate } = await generateSignature('GET', url, headers, emptyPayload, config)\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers: requestHeaders,\n      proxy\n    })\n\n    if (response.status === 200) {\n      const content = await response.text()\n      const etag = response.headers.get('ETag') || ''\n      const lastModified = response.headers.get('Last-Modified') || ''\n\n      return { content, etag, lastModified }\n    } else if (response.status === 404) {\n      // 文件不存在\n      return null\n    } else {\n      const errorText = await response.text()\n      console.error('S3 Download failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('S3 Download error:', error)\n    return null\n  }\n}\n\n/**\n * 删除 S3 文件\n */\nexport async function s3Delete(config: S3Config, key: string, proxy?: Proxy): Promise<boolean> {\n  try {\n    const url = buildS3Url(config, key)\n\n    const emptyPayload = new ArrayBuffer(0)\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload)\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('')\n\n    const headers: Record<string, string> = {\n      Host: new URL(url).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    }\n\n    const { authorization, amzDate } = await generateSignature('DELETE', url, headers, emptyPayload, config)\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(url, {\n      method: 'DELETE',\n      headers: requestHeaders,\n      proxy\n    })\n\n    // 204 No Content 或 200 OK 都表示删除成功\n    return response.status === 204 || response.status === 200\n  } catch (error) {\n    console.error('S3 Delete error:', error)\n    return false\n  }\n}\n\n/**\n * 列出 S3 文件（用于获取文件列表）\n */\nexport async function s3ListObjects(\n  config: S3Config,\n  prefix: string,\n  proxy?: Proxy\n): Promise<Array<{ key: string; etag: string; lastModified: string; size: number }>> {\n  try {\n    const baseUrl = buildS3BaseUrl(config)\n\n    // 处理 pathPrefix\n    const configPrefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n    const fullPrefix = configPrefix ? `${configPrefix}/${prefix}` : prefix\n\n    // 构建 ListObjectsV2 URL\n    const listUrl = new URL(baseUrl)\n    listUrl.searchParams.set('list-type', '2')\n    listUrl.searchParams.set('prefix', fullPrefix)\n\n    const urlStr = listUrl.toString()\n\n    const emptyPayload = new ArrayBuffer(0)\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload)\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('')\n\n    const headers: Record<string, string> = {\n      Host: new URL(urlStr).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    }\n\n    const { authorization, amzDate } = await generateSignature('GET', urlStr, headers, emptyPayload, config)\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(urlStr, {\n      method: 'GET',\n      headers: requestHeaders,\n      proxy\n    })\n\n    if (response.status === 200) {\n      const xmlText = await response.text()\n      const result = parseListObjectsResponse(xmlText, configPrefix)\n      return result\n    } else {\n      const errorText = await response.text()\n      console.error('S3 ListObjects failed:', response.status, errorText)\n      return []\n    }\n  } catch (error) {\n    console.error('S3 ListObjects error:', error)\n    return []\n  }\n}\n\n/**\n * 解析 ListObjectsV2 响应 XML\n */\nfunction parseListObjectsResponse(\n  xml: string,\n  prefix: string\n): Array<{ key: string; etag: string; lastModified: string; size: number }> {\n  const results: Array<{ key: string; etag: string; lastModified: string; size: number }> = []\n\n  // 提取所有 Contents 节点\n  const contentsRegex = /<Contents>([\\s\\S]*?)<\\/Contents>/g\n  let match\n\n  while ((match = contentsRegex.exec(xml)) !== null) {\n    const content = match[1]\n\n    // 提取 Key\n    const keyMatch = /<Key>(.*?)<\\/Key>/.exec(content)\n    // 提取 ETag\n    const etagMatch = /<ETag>(.*?)<\\/ETag>/.exec(content)\n    // 提取 LastModified\n    const lastModifiedMatch = /<LastModified>(.*?)<\\/LastModified>/.exec(content)\n    // 提取 Size\n    const sizeMatch = /<Size>(.*?)<\\/Size>/.exec(content)\n\n    if (keyMatch) {\n      let key = keyMatch[1]\n\n      // 移除 prefix 前缀，还原相对路径\n      if (prefix && key.startsWith(prefix + '/')) {\n        key = key.substring(prefix.length + 1)\n      }\n\n      results.push({\n        key,\n        etag: etagMatch ? etagMatch[1].replace(/\"/g, '') : '',\n        lastModified: lastModifiedMatch ? lastModifiedMatch[1] : '',\n        size: sizeMatch ? parseInt(sizeMatch[1], 10) : 0\n      })\n    }\n  }\n\n  return results\n}\n\n/**\n * 获取文件信息\n */\nexport async function s3HeadObject(\n  config: S3Config,\n  key: string,\n  proxy?: Proxy\n): Promise<{ etag: string; lastModified: string } | null> {\n  try {\n    const url = buildS3Url(config, key)\n\n    const emptyPayload = new ArrayBuffer(0)\n    const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload)\n    const payloadHashHex = Array.from(new Uint8Array(payloadHash))\n      .map(b => b.toString(16).padStart(2, '0'))\n      .join('')\n\n    const headers: Record<string, string> = {\n      Host: new URL(url).host,\n      'X-Amz-Content-Sha256': payloadHashHex\n    }\n\n    const { authorization, amzDate } = await generateSignature('HEAD', url, headers, emptyPayload, config)\n\n    const requestHeaders = new Headers()\n    requestHeaders.append('Authorization', authorization)\n    requestHeaders.append('X-Amz-Date', amzDate)\n    requestHeaders.append('X-Amz-Content-Sha256', payloadHashHex)\n\n    const response = await fetch(url, {\n      method: 'HEAD',\n      headers: requestHeaders,\n      proxy\n    })\n\n    if (response.status === 200) {\n      const etag = response.headers.get('ETag') || ''\n      const lastModified = response.headers.get('Last-Modified') || ''\n\n      return { etag, lastModified }\n    } else if (response.status === 404) {\n      // 文件不存在\n      return null\n    } else {\n      const errorText = await response.text()\n      console.error('S3 HeadObject failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('S3 HeadObject error:', error)\n    return null\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/sync-manager.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { calculateFileSha, getLocalFileMetadata, getRemoteFileInfo, compareFileVersions, pullRemoteFile, saveLocalFile, setLocalRecordedSha } from './auto-sync'\nimport { decodeBase64ToString } from './github'\nimport { updateFileSyncTime } from './conflict-resolution'\nimport { getSyncRepoName } from './repo-utils'\nimport { uploadFile as uploadToGithub, getFiles as getGithubFiles, deleteFile as deleteGithubFile } from './github'\nimport { uploadFile as uploadToGitee, getFiles as getGiteeFiles, deleteFile as deleteGiteeFile } from './gitee'\nimport { uploadFile as uploadToGitlab, getFileContent as getGitlabFile, deleteFile as deleteGitlabFile } from './gitlab'\nimport { uploadFile as uploadToGitea, getFileContent as getGiteaFile, deleteFile as deleteGiteaFile } from './gitea'\nimport { s3Upload, s3Download, s3Delete } from './s3'\nimport { webdavUpload, webdavDownload, webdavDelete } from './webdav'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\nimport useSyncStore from '@/stores/sync'\nimport { toast } from '@/hooks/use-toast'\nimport { readTextFile } from '@tauri-apps/plugin-fs'\nimport { getFilePathOptions, getWorkspacePath } from '@/lib/workspace'\nimport { shouldExclude } from '@/config/sync-exclusions'\n\n/**\n * 获取 GitLab 分支配置\n */\nasync function getGitlabBranch(): Promise<string> {\n  const store = await Store.load('store.json')\n  return await store.get<string>('gitlabBranch') || 'main'\n}\n\n/**\n * 获取 Gitea 分支配置\n */\nasync function getGiteaBranch(): Promise<string> {\n  const store = await Store.load('store.json')\n  return await store.get<string>('giteaBranch') || 'main'\n}\n\n/**\n * 获取 S3 配置\n */\nasync function getS3Config(): Promise<S3Config | null> {\n  const store = await Store.load('store.json')\n  const config = await store.get<S3Config>('s3SyncConfig')\n  if (config && config.accessKeyId && config.secretAccessKey && config.region && config.bucket) {\n    return config\n  }\n  return null\n}\n\n/**\n * 获取 WebDAV 配置\n */\nasync function getWebDAVConfig(): Promise<WebDAVConfig | null> {\n  const store = await Store.load('store.json')\n  const config = await store.get<WebDAVConfig>('webdavSyncConfig')\n  if (config && config.url && config.username && config.password) {\n    return config\n  }\n  return null\n}\n\n// 同步配置\nexport interface SyncConfig {\n  autoSync: boolean           // 自动同步总开关\n  autoPushOnSave: boolean     // 保存时自动推送\n  autoPullOnOpen: boolean     // 打开时自动拉取\n  autoPullOnSwitch: boolean   // 切换文件时自动拉取\n  conflictPolicy: 'ask' | 'local' | 'remote'\n}\n\nexport const defaultSyncConfig: SyncConfig = {\n  autoSync: true,\n  autoPushOnSave: true,\n  autoPullOnOpen: false,      // 默认关闭\n  autoPullOnSwitch: false,    // 默认关闭\n  conflictPolicy: 'ask'\n}\n\n// 同步状态\nexport interface SyncState {\n  isSyncing: boolean          // 是否正在同步\n  pendingSync: boolean         // 是否有待同步的变更\n  lastSyncTime: number        // 最后同步时间\n  lastSyncSha: string         // 最后同步的 SHA\n  syncStatus: 'synced' | 'local_newer' | 'remote_newer' | 'conflict' | 'unknown'\n}\n\n// 同步结果\nexport interface SyncResult {\n  success: boolean\n  action: 'push' | 'pull' | 'delete' | 'none' | 'conflict'\n  message?: string\n  error?: string\n}\n\n// 同步日志\nexport interface SyncLog {\n  timestamp: number\n  action: 'push' | 'pull' | 'delete'\n  filePath: string\n  success: boolean\n  error?: string\n}\n\n// 同步管理器\nexport class SyncManager {\n  private config: SyncConfig = { ...defaultSyncConfig }\n  private state: SyncState = {\n    isSyncing: false,\n    pendingSync: false,\n    lastSyncTime: 0,\n    lastSyncSha: '',\n    syncStatus: 'unknown'\n  }\n  private syncQueue: Map<string, { timestamp: number }> = new Map()\n  private throttleTimer: ReturnType<typeof setTimeout> | null = null\n\n  constructor() {\n    this.loadConfig()\n  }\n\n  /**\n   * 加载配置\n   */\n  async loadConfig(): Promise<void> {\n    try {\n      // 先从 sync_config.json 加载配置\n      const syncStore = await Store.load('sync_config.json')\n      const savedConfig = await syncStore.get<SyncConfig>('config')\n      if (savedConfig) {\n        this.config = { ...defaultSyncConfig, ...savedConfig }\n      }\n\n      // 再从 store.json 读取设置中的 autoPull 配置\n      const settingStore = await Store.load('store.json')\n      const autoPullOnOpen = await settingStore.get<boolean>('autoPullOnOpen')\n      const autoPullOnSwitch = await settingStore.get<boolean>('autoPullOnSwitch')\n\n      // 覆盖配置\n      if (autoPullOnOpen !== undefined && autoPullOnOpen !== null) {\n        this.config.autoPullOnOpen = autoPullOnOpen\n      }\n      if (autoPullOnSwitch !== undefined && autoPullOnSwitch !== null) {\n        this.config.autoPullOnSwitch = autoPullOnSwitch\n      }\n    } catch {\n      // 静默处理配置加载错误\n    }\n  }\n\n  /**\n   * 保存配置\n   */\n  async saveConfig(): Promise<void> {\n    try {\n      const store = await Store.load('sync_config.json')\n      await store.set('config', this.config)\n      await store.save()\n    } catch {\n      // 静默处理配置保存错误\n    }\n  }\n\n  /**\n   * 更新配置\n   */\n  async updateConfig(config: Partial<SyncConfig>): Promise<void> {\n    this.config = { ...this.config, ...config }\n    await this.saveConfig()\n  }\n\n  /**\n   * 获取配置\n   */\n  getConfig(): SyncConfig {\n    return { ...this.config }\n  }\n\n  /**\n   * 获取同步状态\n   */\n  getState(): SyncState {\n    return { ...this.state }\n  }\n\n  /**\n   * 获取当前使用的平台\n   */\n  async getCurrentPlatform(): Promise<string> {\n    const store = await Store.load('store.json')\n    return await store.get<string>('primaryBackupMethod') || 'github'\n  }\n\n  /**\n   * 计算文件的 SHA\n   */\n  async calculateSha(content: string): Promise<string> {\n    return await calculateFileSha(content)\n  }\n\n  /**\n   * 获取本地文件 SHA\n   */\n  async getLocalSha(path: string): Promise<string | null> {\n    const meta = await getLocalFileMetadata(path)\n    return meta.localSha || null\n  }\n\n  /**\n   * 获取远程文件 SHA\n   */\n  async getRemoteSha(path: string): Promise<string | null> {\n    const info = await getRemoteFileInfo(path)\n    return info.sha || null\n  }\n\n  /**\n   * 推送文件到远程\n   */\n  async pushFile(path: string, content: string): Promise<SyncResult> {\n    // 检查是否应该排除\n    if (shouldExclude(path)) {\n      return { success: true, action: 'none', message: '文件被排除在同步之外' }\n    }\n\n    try {\n      const platform = await this.getCurrentPlatform() as 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n      // S3 不需要 repo，直接设为空字符串\n      const repo = (platform === 's3' || platform === 'webdav') ? '' : await getSyncRepoName(platform)\n      const sha = (platform === 's3' || platform === 'webdav') ? undefined : await this.getRemoteSha(path) || undefined\n      const message = `Sync: ${path} - ${new Date().toLocaleString('zh-CN')}`\n      const filename = path.split('/').pop() || path\n\n      let uploadSuccess = false\n\n      switch (platform) {\n        case 'github': {\n          const result = await uploadToGithub({ file: content, sha, message, repo, path, filename })\n          uploadSuccess = !!result\n          break\n        }\n        case 'gitee': {\n          const result = await uploadToGitee({ file: content, sha, message, repo, path, filename })\n          uploadSuccess = !!result\n          break\n        }\n        case 'gitlab': {\n          const result = await uploadToGitlab({ file: content, sha, message, repo, path, filename })\n          uploadSuccess = !!result\n          break\n        }\n        case 'gitea': {\n          const result = await uploadToGitea({ file: content, sha, message, repo, path, filename })\n          uploadSuccess = !!result\n          break\n        }\n        case 's3': {\n          const s3Config = await getS3Config()\n          if (!s3Config) {\n            return { success: false, action: 'push', error: 'S3 配置未找到' }\n          }\n          // S3 使用相对路径作为 key，不需要添加 pathPrefix\n          const result = await s3Upload(s3Config, path, content)\n          uploadSuccess = !!result\n          if (uploadSuccess && result) {\n            // 更新 ETag 记录\n            useSyncStore.getState().updateS3FileEtag(path, result.etag)\n          }\n          break\n        }\n        case 'webdav': {\n          const webdavConfig = await getWebDAVConfig()\n          if (!webdavConfig) {\n            return { success: false, action: 'push', error: 'WebDAV 配置未找到' }\n          }\n          const result = await webdavUpload(webdavConfig, path, content)\n          uploadSuccess = !!result\n          if (uploadSuccess && result) {\n            // 更新 ETag 记录\n            useSyncStore.getState().updateWebDAVFileEtag(path, result.etag)\n          }\n          break\n        }\n      }\n\n      if (uploadSuccess) {\n        // 推送成功后更新本地记录的远程 SHA\n        if (platform !== 's3' && platform !== 'webdav') {\n          const newRemoteSha = await this.getRemoteSha(path)\n          if (newRemoteSha) {\n            await setLocalRecordedSha(path, newRemoteSha)\n          }\n        }\n        await this.logSync(path, 'push', true)\n        return { success: true, action: 'push', message: '推送成功' }\n      }\n\n      await this.logSync(path, 'push', false, '推送失败')\n      return { success: false, action: 'push', error: '推送失败' }\n    } catch (error) {\n      await this.logSync(path, 'push', false, String(error))\n      return { success: false, action: 'push', error: String(error) }\n    }\n  }\n\n  /**\n   * 从远程拉取文件\n   */\n  async pullFile(path: string): Promise<SyncResult> {\n    try {\n      const platform = await this.getCurrentPlatform() as 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n      // S3 不需要 repo\n      const repo = (platform === 's3' || platform === 'webdav') ? '' : await getSyncRepoName(platform)\n\n      let content: string | undefined\n\n      switch (platform) {\n        case 'github':\n          const githubFile = await getGithubFiles({ path, repo })\n          content = githubFile?.content\n          break\n        case 'gitee':\n          const giteeFile = await getGiteeFiles({ path, repo })\n          content = giteeFile?.content\n          break\n        case 'gitlab': {\n          const branch = await getGitlabBranch()\n          const gitlabFile = await getGitlabFile({ path, ref: branch, repo })\n          content = gitlabFile?.content\n          break\n        }\n        case 'gitea': {\n          const branch = await getGiteaBranch()\n          const giteaFile = await getGiteaFile({ path, ref: branch, repo })\n          content = giteaFile?.content\n          break\n        }\n        case 's3': {\n          const s3Config = await getS3Config()\n          if (!s3Config) {\n            return { success: false, action: 'pull', error: 'S3 配置未找到' }\n          }\n          // S3 使用相对路径作为 key\n          const s3File = await s3Download(s3Config, path)\n          if (s3File) {\n            content = s3File.content\n            // 更新 ETag 记录\n            useSyncStore.getState().updateS3FileEtag(path, s3File.etag)\n          }\n          break\n        }\n        case 'webdav': {\n          const webdavConfig = await getWebDAVConfig()\n          if (!webdavConfig) {\n            return { success: false, action: 'pull', error: 'WebDAV 配置未找到' }\n          }\n          const webdavFile = await webdavDownload(webdavConfig, path)\n          if (webdavFile) {\n            content = webdavFile.content\n            // 更新 ETag 记录\n            useSyncStore.getState().updateWebDAVFileEtag(path, webdavFile.etag)\n          }\n          break\n        }\n      }\n\n      if (content) {\n        // S3 和 WebDAV 不需要 base64 解码，其他平台需要\n        let decodedContent = content\n        if (platform !== 's3' && platform !== 'webdav') {\n          decodedContent = decodeBase64ToString(content)\n        }\n        await saveLocalFile(path, decodedContent)\n\n        // 获取远程文件的 SHA 并更新本地记录\n        if (platform !== 's3' && platform !== 'webdav') {\n          const remoteSha = await this.getRemoteSha(path)\n          if (remoteSha) {\n            await setLocalRecordedSha(path, remoteSha)\n          }\n        }\n\n        await updateFileSyncTime(path)\n        await this.logSync(path, 'pull', true)\n        return { success: true, action: 'pull', message: '拉取成功' }\n      }\n\n      await this.logSync(path, 'pull', false, '文件不存在')\n      return { success: false, action: 'pull', error: '远程文件不存在' }\n    } catch (error) {\n      await this.logSync(path, 'pull', false, String(error))\n      return { success: false, action: 'pull', error: String(error) }\n    }\n  }\n\n  /**\n   * 删除远程文件\n   */\n  async deleteRemoteFile(path: string): Promise<SyncResult> {\n    try {\n      const platform = await this.getCurrentPlatform() as 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n      // S3 不需要 repo\n      const repo = (platform === 's3' || platform === 'webdav') ? '' : await getSyncRepoName(platform)\n      const sha = (platform === 's3' || platform === 'webdav') ? undefined : await this.getRemoteSha(path)\n\n      // S3 和 WebDAV 不需要 SHA，但其他平台需要\n      if ((platform !== 's3' && platform !== 'webdav') && !sha) {\n        return { success: true, action: 'none', message: '远程文件不存在，无需删除' }\n      }\n\n      let success = false\n\n      switch (platform) {\n        case 'github':\n          success = !!(await deleteGithubFile({ path, sha: sha!, repo }))\n          break\n        case 'gitee':\n          success = !!(await deleteGiteeFile({ path, sha: sha!, repo }))\n          break\n        case 'gitlab':\n          success = !!(await deleteGitlabFile({ path, sha: sha!, repo }))\n          break\n        case 'gitea':\n          success = !!(await deleteGiteaFile({ path, sha: sha!, repo }))\n          break\n        case 's3': {\n          const s3Config = await getS3Config()\n          if (!s3Config) {\n            return { success: false, action: 'delete', error: 'S3 配置未找到' }\n          }\n          // S3 使用相对路径作为 key\n          success = await s3Delete(s3Config, path)\n          if (success) {\n            // 移除 ETag 记录\n            useSyncStore.getState().removeS3FileEtag(path)\n          }\n          break\n        }\n        case 'webdav': {\n          const webdavConfig = await getWebDAVConfig()\n          if (!webdavConfig) {\n            return { success: false, action: 'delete', error: 'WebDAV 配置未找到' }\n          }\n          success = await webdavDelete(webdavConfig, path)\n          if (success) {\n            // 移除 ETag 记录\n            useSyncStore.getState().removeWebDAVFileEtag(path)\n          }\n          break\n        }\n      }\n\n      if (success) {\n        await this.logSync(path, 'delete', true)\n        return { success: true, action: 'delete', message: '删除成功' }\n      }\n\n      await this.logSync(path, 'delete', false, '删除失败')\n      return { success: false, action: 'delete', error: '删除失败' }\n    } catch (error) {\n      await this.logSync(path, 'delete', false, String(error))\n      return { success: false, action: 'delete', error: String(error) }\n    }\n  }\n\n  /**\n   * 处理冲突\n   */\n  async resolveConflict(path: string, strategy: 'ask' | 'local' | 'remote', localContent?: string, remoteContent?: string): Promise<SyncResult> {\n    try {\n      // 如果策略是 ask，需要获取用户选择\n      if (strategy === 'ask') {\n        // 这里会通过 UI 弹窗让用户选择，实际处理在外部\n        return { success: false, action: 'conflict', message: '需要用户选择' }\n      }\n\n      // 获取内容\n      if (!localContent) {\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n        try {\n          localContent = workspace.isCustom\n            ? await readTextFile(pathOptions.path)\n            : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        } catch {\n          localContent = ''\n        }\n      }\n\n      if (!remoteContent) {\n        remoteContent = await pullRemoteFile(path)\n      }\n\n      switch (strategy) {\n        case 'local':\n          // 保留本地，删除远程然后重新上传\n          await this.deleteRemoteFile(path)\n          await this.pushFile(path, localContent)\n          toast({ title: '冲突处理', description: '保留本地版本' })\n          break\n        case 'remote':\n          // 使用远程版本\n          await saveLocalFile(path, remoteContent)\n          await updateFileSyncTime(path)\n          toast({ title: '冲突处理', description: '使用远程版本' })\n          break\n      }\n\n      return { success: true, action: 'push', message: '冲突已解决' }\n    } catch (error) {\n      return { success: false, action: 'conflict', error: String(error) }\n    }\n  }\n\n  /**\n   * 同步单个文件\n   */\n  async syncFile(path: string, options: {\n    onConflict?: (local: string, remote: string) => Promise<'local' | 'remote' | 'cancel'>\n  } = {}): Promise<SyncResult> {\n    // 检查是否正在同步\n    if (this.state.isSyncing) {\n      this.state.pendingSync = true\n      return { success: true, action: 'none', message: '同步中，标记待同步' }\n    }\n\n    this.state.isSyncing = true\n\n    try {\n      // 获取本地和远程的 SHA\n      const localSha = await this.getLocalSha(path)\n      const remoteSha = await this.getRemoteSha(path)\n\n      // 比较版本\n      const syncResult = await compareFileVersions(path)\n\n      if (syncResult.action === 'none') {\n        return { success: true, action: 'none', message: '文件已同步' }\n      }\n\n      if (syncResult.action === 'push') {\n        // 推送本地版本\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n        const content = workspace.isCustom\n          ? await readTextFile(pathOptions.path)\n          : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n\n        const result = await this.pushFile(path, content)\n        this.state.lastSyncTime = Date.now()\n        this.state.lastSyncSha = localSha || ''\n        return result\n      }\n\n      if (syncResult.action === 'pull') {\n        // 拉取远程版本\n        const result = await this.pullFile(path)\n        this.state.lastSyncTime = Date.now()\n        this.state.lastSyncSha = remoteSha || ''\n        return result\n      }\n\n      if (syncResult.action === 'conflict' && options.onConflict) {\n        // 处理冲突\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n        const localContent = workspace.isCustom\n          ? await readTextFile(pathOptions.path)\n          : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        const remoteContent = await pullRemoteFile(path)\n        const choice = await options.onConflict(localContent, remoteContent)\n\n        if (choice === 'cancel') {\n          return { success: false, action: 'conflict', error: '用户取消' }\n        }\n\n        return await this.resolveConflict(path, choice, localContent, remoteContent)\n      }\n\n      return { success: true, action: 'none' }\n    } finally {\n      this.state.isSyncing = false\n\n      // 如果有待同步的变更，继续同步\n      if (this.state.pendingSync) {\n        this.state.pendingSync = false\n        await this.syncFile(path, options)\n      }\n    }\n  }\n\n  /**\n   * 保存时触发推送（带节流）\n   */\n  async onSave(path: string): Promise<void> {\n    if (!this.config.autoSync || !this.config.autoPushOnSave) {\n      return\n    }\n\n    // 检查是否应该排除\n    if (shouldExclude(path)) {\n      return\n    }\n\n    // 标记该路径需要同步（内容从磁盘读取）\n    this.syncQueue.set(path, { timestamp: Date.now() })\n\n    // 如果正在同步，标记待同步\n    if (this.state.isSyncing) {\n      this.state.pendingSync = true\n      return\n    }\n\n    // 节流 2 秒\n    if (this.throttleTimer) {\n      clearTimeout(this.throttleTimer)\n    }\n\n    this.throttleTimer = setTimeout(async () => {\n      await this.processSyncQueue()\n    }, 2000)\n  }\n\n  /**\n   * 打开时触发拉取\n   * 返回 { updated: true, content: string } 如果拉取了新内容\n   */\n  async onOpen(path: string): Promise<{ updated: boolean; content?: string } | null> {\n    if (!this.config.autoSync || !this.config.autoPullOnOpen) {\n      return null\n    }\n\n    // 检查是否应该排除\n    if (shouldExclude(path)) {\n      return null\n    }\n\n    // 比较版本，决定是否需要拉取\n    const syncResult = await compareFileVersions(path)\n\n    if (syncResult.action === 'pull') {\n      const result = await this.pullFile(path)\n      if (result.success && result.action === 'pull') {\n        // 读取拉取的内容并返回\n        try {\n          const { pullRemoteFile } = await import('./auto-sync')\n          const content = await pullRemoteFile(path)\n          return { updated: true, content }\n        } catch {\n          return { updated: true }\n        }\n      }\n      return { updated: result.success }\n    }\n\n    // 处理冲突情况：远程文件较新但 SHA 不同（可能是同步过的）\n    if (syncResult.action === 'conflict') {\n      const result = await this.pullFile(path)\n      if (result.success && result.action === 'pull') {\n        try {\n          const { pullRemoteFile } = await import('./auto-sync')\n          const content = await pullRemoteFile(path)\n          return { updated: true, content }\n        } catch {\n          return { updated: true }\n        }\n      }\n      return { updated: result.success }\n    }\n\n    return null\n  }\n\n  /**\n   * 处理同步队列\n   */\n  private async processSyncQueue(): Promise<void> {\n    this.state.isSyncing = true\n\n    try {\n      for (const [path] of this.syncQueue) {\n        // 始终从磁盘读取最新内容，确保上传的是本地最新内容\n        const { getFilePathOptions, getWorkspacePath } = await import('@/lib/workspace')\n        const { readTextFile } = await import('@tauri-apps/plugin-fs')\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n\n        let content: string\n        if (workspace.isCustom) {\n          content = await readTextFile(pathOptions.path)\n        } else {\n          content = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        const result = await this.pushFile(path, content)\n        if (result.success) {\n          this.syncQueue.delete(path)\n        }\n      }\n    } finally {\n      this.state.isSyncing = false\n      this.state.pendingSync = false\n    }\n  }\n\n  /**\n   * 同步所有文件\n   */\n  async syncAll(paths: string[]): Promise<SyncResult[]> {\n    const results: SyncResult[] = []\n\n    for (const path of paths) {\n      const result = await this.syncFile(path)\n      results.push(result)\n    }\n\n    return results\n  }\n\n  /**\n   * 记录同步日志\n   */\n  private async logSync(filePath: string, action: 'push' | 'pull' | 'delete', success: boolean, error?: string): Promise<void> {\n    try {\n      const store = await Store.load('sync_logs.json')\n      const logs = await store.get<SyncLog[]>('logs') || []\n\n      logs.unshift({\n        timestamp: Date.now(),\n        action,\n        filePath,\n        success,\n        error\n      })\n\n      // 只保留最近 100 条\n      if (logs.length > 100) {\n        logs.splice(100)\n      }\n\n      await store.set('logs', logs)\n      await store.save()\n    } catch {\n    }\n  }\n\n  /**\n   * 获取同步日志\n   */\n  async getLogs(limit?: number): Promise<SyncLog[]> {\n    try {\n      const store = await Store.load('sync_logs.json')\n      const logs = await store.get<SyncLog[]>('logs') || []\n      return limit ? logs.slice(0, limit) : logs\n    } catch {\n      return []\n    }\n  }\n\n  /**\n   * 清除同步日志\n   */\n  async clearLogs(): Promise<void> {\n    try {\n      const store = await Store.load('sync_logs.json')\n      await store.set('logs', [])\n      await store.save()\n    } catch {\n    }\n  }\n\n  /**\n   * 获取文件的同步状态\n   */\n  async getFileSyncStatus(path: string): Promise<SyncState['syncStatus']> {\n    const localSha = await this.getLocalSha(path)\n    const remoteSha = await this.getRemoteSha(path)\n\n    if (!localSha && !remoteSha) {\n      return 'unknown'\n    }\n\n    if (!localSha) {\n      return 'remote_newer'\n    }\n\n    if (!remoteSha) {\n      return 'local_newer'\n    }\n\n    if (localSha === remoteSha) {\n      return 'synced'\n    }\n\n    return 'conflict'\n  }\n}\n\n// 单例实例\nlet syncManager: SyncManager | null = null\n\nexport function getSyncManager(): SyncManager {\n  if (!syncManager) {\n    syncManager = new SyncManager()\n  }\n  return syncManager\n}\n\n// 便捷函数\nexport async function syncOnSave(path: string): Promise<void> {\n  const manager = getSyncManager()\n  await manager.onSave(path)\n}\n\nexport async function syncOnOpen(path: string): Promise<{ updated: boolean; content?: string } | null> {\n  const manager = getSyncManager()\n  return await manager.onOpen(path)\n}\n\nexport async function syncSingleFile(path: string, onConflict?: (local: string, remote: string) => Promise<'local' | 'remote' | 'cancel'>): Promise<SyncResult> {\n  const manager = getSyncManager()\n  return await manager.syncFile(path, { onConflict })\n}\n\n/**\n * 检查同步是否已配置\n * 检查是否有选择同步平台并配置了对应的访问令牌\n */\nexport async function isSyncConfigured(): Promise<boolean> {\n  try {\n    const store = await Store.load('store.json')\n    const platform = await store.get<string>('primaryBackupMethod')\n\n    // 如果没有选择平台，返回 false\n    if (!platform) {\n      return false\n    }\n\n    // 检查对应平台的访问令牌（确保不是空字符串）\n    const token = await store.get<string>('accessToken')\n    switch (platform) {\n      case 'github':\n        return !!(token && token.trim().length > 0)\n      case 'gitee': {\n        const giteeToken = await store.get<string>('giteeAccessToken')\n        return !!(giteeToken && giteeToken.trim().length > 0)\n      }\n      case 'gitlab': {\n        const gitlabToken = await store.get<string>('gitlabAccessToken')\n        return !!(gitlabToken && gitlabToken.trim().length > 0)\n      }\n      case 'gitea': {\n        const giteaToken = await store.get<string>('giteaAccessToken')\n        return !!(giteaToken && giteaToken.trim().length > 0)\n      }\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        return !!(\n          s3Config &&\n          s3Config.accessKeyId &&\n          s3Config.secretAccessKey &&\n          s3Config.region &&\n          s3Config.bucket\n        )\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        return !!(\n          webdavConfig &&\n          webdavConfig.url &&\n          webdavConfig.username &&\n          webdavConfig.password\n        )\n      }\n      default:\n        return false\n    }\n  } catch {\n    return false\n  }\n}\n"
  },
  {
    "path": "src/lib/sync/sync-push-queue.ts",
    "content": "'use client'\n\nimport { Store } from '@tauri-apps/plugin-store'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { getWorkspacePath, getFilePathOptions } from '@/lib/workspace'\nimport { readTextFile } from '@tauri-apps/plugin-fs'\nimport emitter from '@/lib/emitter'\nimport { pullRemoteFile, setLocalRecordedSha, getLocalRecordedSha } from './auto-sync'\nimport { getRemoteFileInfo } from './auto-sync'\nimport useSettingStore from '@/stores/setting'\nimport useSyncStore from '@/stores/sync'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\n\n/**\n * 获取 S3 配置\n */\nasync function getS3Config(): Promise<S3Config | null> {\n  const store = await Store.load('store.json')\n  const config = await store.get<S3Config>('s3SyncConfig')\n  if (config && config.accessKeyId && config.secretAccessKey && config.region && config.bucket) {\n    return config\n  }\n  return null\n}\n\n/**\n * 获取 WebDAV 配置\n */\nasync function getWebDAVConfig(): Promise<WebDAVConfig | null> {\n  const store = await Store.load('store.json')\n  const config = await store.get<WebDAVConfig>('webdavSyncConfig')\n  if (config && config.url && config.username && config.password) {\n    return config\n  }\n  return null\n}\n\n/**\n * 获取代理配置\n */\nasync function getProxyConfig(): Promise<{ all: string } | undefined> {\n  const store = await Store.load('store.json')\n  const proxyUrl = await store.get<string>('proxy')\n  return proxyUrl ? { all: proxyUrl } : undefined\n}\n\ninterface PushTask {\n  path: string\n  timestamp: number\n}\n\n// 使用模块级变量来跟踪初始化状态，避免 HMR 重复注册\nlet initialized = false\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet articleSavedListener: any = null\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet editorInputListener: any = null\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet syncPulledListener: any = null\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet articleOpenedListener: any = null\n\nclass SyncPushQueue {\n  private queue: PushTask[] = []\n  private isProcessing = false\n  private debounceTimer: ReturnType<typeof setTimeout> | null = null\n  private lastInputTime: number = Date.now()\n\n  private get IDLE_THRESHOLD(): number {\n    // 动态读取 autoSync 设置\n    const state = useSettingStore.getState()\n    if (!state) return 0\n\n    const { autoSync } = state\n\n    if (!autoSync || autoSync === 'disabled') {\n      return 0 // 禁用自动同步\n    }\n    return parseInt(autoSync, 10) * 1000\n  }\n\n  private readonly CHECK_INTERVAL = 100 // 每 100ms 检查一次\n\n  /**\n   * 初始化监听器 - 只执行一次\n   */\n  init() {\n    if (initialized) return\n    initialized = true\n    this.initListeners()\n  }\n\n  private initListeners() {\n    // 移除旧的监听器（如果有）\n    this.removeListeners()\n\n    // 监听文章保存事件\n    articleSavedListener = ((event: { path: string; content: string }) => {\n      this.addTask(event.path)\n    }) as any\n    emitter.on('article-saved', articleSavedListener)\n\n    // 监听用户输入事件，重置计时器\n    editorInputListener = (() => {\n      this.lastInputTime = Date.now()\n    }) as any\n    emitter.on('editor-input', editorInputListener)\n\n    // 监听拉取完成事件，重置计时器\n    syncPulledListener = (() => {\n      this.lastInputTime = Date.now()\n    }) as any\n    emitter.on('sync-pulled', syncPulledListener)\n\n    // 监听文件切换事件，重置计时器\n    articleOpenedListener = (() => {\n      this.lastInputTime = Date.now()\n    }) as any\n    emitter.on('article-opened', articleOpenedListener)\n  }\n\n  private removeListeners() {\n    if (articleSavedListener) {\n      emitter.off('article-saved', articleSavedListener)\n    }\n    if (editorInputListener) {\n      emitter.off('editor-input', editorInputListener)\n    }\n    if (syncPulledListener) {\n      emitter.off('sync-pulled', syncPulledListener)\n    }\n    if (articleOpenedListener) {\n      emitter.off('article-opened', articleOpenedListener)\n    }\n    articleSavedListener = null\n    editorInputListener = null\n    syncPulledListener = null\n    articleOpenedListener = null\n  }\n\n  /**\n   * 添加任务到队列 - 只保留最新的任务\n   * 每次调用都会重新开始 10 秒计时\n   */\n  addTask(path: string) {\n    const now = Date.now()\n    const task: PushTask = {\n      path,\n      timestamp: now\n    }\n\n    // 重置 lastInputTime，确保从现在开始计算 10 秒\n    this.lastInputTime = now\n\n    // 如果当前有任务正在处理\n    if (this.isProcessing) {\n      // Bug fix: Instead of silently dropping, add the task to queue for processing\n      // This ensures all file changes are eventually synced\n      this.queue.push(task)\n      return\n    }\n\n    // 清空队列，只保留最新任务\n    this.queue = [task]\n\n    // 设置防抖定时器\n    this.scheduleFlush()\n  }\n\n  /**\n   * 防抖调度 - 用户停止输入后执行推送\n   */\n  private scheduleFlush() {\n    // 如果自动同步被禁用，直接返回\n    if (!this.IDLE_THRESHOLD) {\n      return\n    }\n\n    if (this.debounceTimer) {\n      clearTimeout(this.debounceTimer)\n    }\n\n    const checkIdle = () => {\n      const now = Date.now()\n      const timeSinceInput = now - this.lastInputTime\n\n      if (timeSinceInput >= this.IDLE_THRESHOLD) {\n        // 用户停止输入超过等待时间，执行推送\n        this.flush()\n      } else {\n        // 继续等待\n        this.debounceTimer = setTimeout(checkIdle, this.CHECK_INTERVAL)\n      }\n    }\n\n    this.debounceTimer = setTimeout(checkIdle, this.CHECK_INTERVAL)\n  }\n\n  /**\n   * 清空队列并处理任务\n   * Bug fix: Process all tasks in the queue, not just the last one\n   */\n  private async flush() {\n    if (this.isProcessing || this.queue.length === 0) {\n      return\n    }\n\n    // Bug fix: Process all tasks in the queue (newest first)\n    // Group by path - keep only the newest task for each path\n    const taskMap = new Map<string, PushTask>()\n    while (this.queue.length > 0) {\n      const task = this.queue.shift()!\n      // Only keep the newest task for each path\n      const existing = taskMap.get(task.path)\n      if (!existing || task.timestamp > existing.timestamp) {\n        taskMap.set(task.path, task)\n      }\n    }\n    const tasksToProcess = Array.from(taskMap.values()).sort((a, b) => b.timestamp - a.timestamp)\n\n    this.isProcessing = false // Will be set to true in the loop\n\n    // Process each task\n    for (const task of tasksToProcess) {\n      this.isProcessing = true\n\n      try {\n        // Wait for file system to complete write\n        await new Promise(resolve => setTimeout(resolve, 100))\n        // 发送开始推送事件\n        emitter.emit('sync-push-started', { path: task.path })\n        await this.pushToRemote(task.path)\n      } catch (error) {\n        console.error(`[SyncPushQueue] Failed to push ${task.path}:`, error)\n      } finally {\n        this.isProcessing = false\n      }\n    }\n\n    // Schedule if there are new tasks\n    if (this.queue.length > 0) {\n      this.scheduleFlush()\n    }\n  }\n\n  /**\n   * 推送到远程仓库\n   */\n  private async pushToRemote(path: string): Promise<{ success: boolean; sha?: string }> {\n    const maxRetries = 3\n\n    for (let attempt = 1; attempt <= maxRetries; attempt++) {\n      try {\n        const store = await Store.load('store.json')\n        const provider = (await store.get<string>('primaryBackupMethod') || 'github') as 'gitee' | 'github' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n        const repo = (provider !== 's3' && provider !== 'webdav') ? await getSyncRepoName(provider) : undefined\n\n        // 从磁盘读取最新内容，确保上传的是本地最新内容\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n        const content = workspace.isCustom\n          ? await readTextFile(pathOptions.path)\n          : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n\n        // 检查本地内容是否与远程相同，如果相同则跳过推送\n        try {\n          const remoteContent = await pullRemoteFile(path)\n          if (remoteContent === content) {\n            // 获取远程 SHA 用于更新文件树\n            const remoteSha = await this.getRemoteSha(path)\n            // 更新本地记录的 SHA，这样下次推送时就会检测到 SHA 匹配而跳过\n            if (remoteSha) {\n              await setLocalRecordedSha(path, remoteSha)\n            }\n            // 发送完成事件\n            emitter.emit('sync-push-completed', { path, success: true, sha: remoteSha })\n            return { success: true, sha: remoteSha }\n          }\n        } catch {\n          // 远程文件不存在或获取失败，继续推送\n        }\n\n        // 生成提交信息\n        const commitMessage = await this.generateCommitMessage(path, content)\n\n        let success = false\n        let uploadedSha: string | undefined\n\n        switch (provider) {\n          case 'github': {\n            const githubModule = await import('@/lib/sync/github') as any\n            // 每次尝试都重新获取远程 SHA，因为远程可能在变化\n            const fileInfo = await githubModule.getFiles({ path, repo })\n\n            // 检查返回的是文件还是目录\n            // GitHub API 对文件返回对象，对目录返回数组\n            // 如果是数组（目录），则无法获取 sha，跳过推送\n            if (Array.isArray(fileInfo)) {\n              console.warn(`[SyncPushQueue] ${path} 是目录，无法推送`)\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n\n            const result = await githubModule.uploadFile({\n              ext: path.split('.').pop() || 'md',\n              file: content,\n              filename: path.split('/').pop() || path,\n              sha: fileInfo?.sha,\n              message: commitMessage,\n              repo,\n              path\n            })\n            // 检查上传是否成功（result 必须存在且有 data）\n            if (result && result.data) {\n              success = true\n              uploadedSha = result?.data?.content?.sha || fileInfo?.sha\n            }\n            break\n          }\n          case 'gitee': {\n            const giteeModule = await import('@/lib/sync/gitee') as any\n            // 每次尝试都重新获取远程 SHA\n            const fileInfo = await giteeModule.getFiles({ path, repo})\n\n            // 检查返回的是文件还是目录\n            // Gitee API 对文件返回对象，对目录返回数组\n            // 如果是数组（目录），则无法获取 sha，跳过推送\n            if (Array.isArray(fileInfo)) {\n              console.warn(`[SyncPushQueue] ${path} 是目录，无法推送`)\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n\n            const result = await giteeModule.uploadFile({\n              ext: path.split('.').pop() || 'md',\n              file: content,\n              filename: path.split('/').pop() || path,\n              sha: fileInfo?.sha,\n              message: commitMessage,\n              repo,\n              path\n            })\n            // 检查上传是否成功\n            if (result && result.data) {\n              success = true\n              // Gitee API 返回的是 result.data.content.sha\n              uploadedSha = result?.data?.content?.sha || fileInfo?.sha\n            }\n            break\n          }\n          case 'gitlab': {\n            const gitlabModule = await import('@/lib/sync/gitlab') as any\n            // 先获取远程文件的 SHA（blob_id），uploadFile 会用它获取 last_commit_id\n            const fileInfo = await gitlabModule.getFiles({ path, repo })\n            // GitLab getFiles 返回文件对象或文件数组，检查是否为数组（目录）\n            if (Array.isArray(fileInfo)) {\n              console.warn(`[SyncPushQueue] ${path} 是目录，无法推送`)\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n            const result = await gitlabModule.uploadFile({\n              file: content,\n              filename: path.split('/').pop() || path,\n              sha: fileInfo?.sha, // GitLab 会用 sha 获取 last_commit_id\n              message: commitMessage,\n              repo,\n              path\n            })\n            // 检查上传是否成功\n            if (result && result.data) {\n              success = true\n              // GitLab 上传成功后从 commit 获取 SHA\n              uploadedSha = await this.getRemoteSha(path)\n            }\n            break\n          }\n          case 'gitea': {\n            const giteaModule = await import('@/lib/sync/gitea') as any\n            // 先获取远程文件的 SHA\n            const fileInfo = await giteaModule.getFiles({ path, repo })\n            // Gitea getFiles 返回文件对象或文件数组，检查是否为数组（目录）\n            if (Array.isArray(fileInfo)) {\n              console.warn(`[SyncPushQueue] ${path} 是目录，无法推送`)\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n            const result = await giteaModule.uploadFile({\n              file: content,\n              filename: path.split('/').pop() || path,\n              sha: fileInfo?.sha, // 传递 SHA 以便 Gitea 进行冲突检测\n              message: commitMessage,\n              repo,\n              path\n            })\n            // 检查上传是否成功\n            if (result && result.data) {\n              success = true\n              // Gitea 上传成功后从 commit 获取 SHA\n              uploadedSha = await this.getRemoteSha(path)\n            }\n            break\n          }\n          case 's3': {\n            const s3Module = await import('@/lib/sync/s3') as any\n            const s3Config = await getS3Config()\n            if (!s3Config) {\n              console.warn('[SyncPushQueue] S3 未配置')\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n\n            // 获取代理配置\n            const proxy = await getProxyConfig()\n\n            // S3 不需要 SHA 检查，直接上传\n            const result = await s3Module.s3Upload(s3Config, path, content, proxy)\n            if (result && result.etag) {\n              success = true\n              uploadedSha = result.etag // 使用 ETag 作为标识\n              // 更新本地记录的 ETag\n              useSyncStore.getState().updateS3FileEtag(path, result.etag)\n            }\n            break\n          }\n          case 'webdav': {\n            const webdavModule = await import('@/lib/sync/webdav') as any\n            const webdavConfig = await getWebDAVConfig()\n            if (!webdavConfig) {\n              console.warn('[SyncPushQueue] WebDAV 未配置')\n              emitter.emit('sync-push-completed', { path, success: false })\n              return { success: false }\n            }\n\n            // 获取代理配置\n            const proxy = await getProxyConfig()\n\n            // WebDAV 不需要 SHA 检查，直接上传\n            const result = await webdavModule.webdavUpload(webdavConfig, path, content, proxy)\n            if (result) {\n              success = true\n              uploadedSha = result.etag || 'uploaded' // 使用 ETag 作为标识，空字符串使用默认值\n              // 更新本地记录的 ETag\n              useSyncStore.getState().updateWebDAVFileEtag(path, result.etag || '')\n            }\n            break\n          }\n        }\n\n        if (success) {\n          // 推送成功后，保存远程 SHA 到本地 store\n          if (uploadedSha) {\n            await setLocalRecordedSha(path, uploadedSha)\n          }\n          emitter.emit('sync-push-completed', { path, success: true, sha: uploadedSha })\n          return { success: true, sha: uploadedSha }\n        } else {\n          // 上传失败（result 为空或无效）\n          emitter.emit('sync-push-completed', { path, success: false })\n          return { success: false }\n        }\n      } catch (error: any) {\n        // 检查是否是 SHA 不匹配错误\n        const errorMessage = error?.message || ''\n        const errorStatus = error?.status || 0\n\n        // SHA 不匹配错误的特征：\n        // 1. HTTP 状态码 422 (Unprocessable Entity) - GitHub/GitLab 常用\n        // 2. HTTP 状态码 409 (Conflict) - 文件冲突\n        // 3. 错误消息包含相关关键词\n        const isShaMismatch =\n          errorStatus === 422 ||\n          errorStatus === 409 ||\n          errorMessage.includes('does not match') ||\n          errorMessage.includes('sha') ||\n          errorMessage.includes('SHA') ||\n          errorMessage.includes('blob') ||\n          errorMessage.includes('conflict') ||\n          errorMessage.includes('out of date') ||\n          errorMessage.includes('已过时') ||\n          errorMessage.includes('冲突')\n\n        // 如果是 SHA 不匹配错误且是首次尝试，显示确认对话框让用户选择\n        if (isShaMismatch && attempt === 1) {\n          // 获取本地记录的 SHA 和远程 SHA\n          const localRecordedSha = await getLocalRecordedSha(path)\n          const remoteFileInfo = await getRemoteFileInfo(path)\n          const remoteFileSha = remoteFileInfo.sha\n\n          // 发射事件让 UI 显示确认对话框\n          emitter.emit('sync-sha-mismatch', {\n            path,\n            localSha: localRecordedSha || undefined,\n            remoteSha: remoteFileSha || undefined,\n            force: false\n          })\n\n          // 不再自动重试，等待用户确认\n          emitter.emit('sync-push-completed', { path, success: false })\n          return { success: false }\n        }\n\n        if (isShaMismatch && attempt < maxRetries) {\n          // 等待一段时间后重试（指数退避）\n          const waitTime = Math.pow(2, attempt - 1) * 500\n          await new Promise(resolve => setTimeout(resolve, waitTime))\n          continue\n        }\n\n        // 如果是最后一次尝试或不是 SHA 错误，打印错误日志\n        if (attempt === maxRetries || !isShaMismatch) {\n          console.error('[SyncPushQueue] 推送失败:', error)\n          emitter.emit('sync-push-completed', { path, success: false })\n          return { success: false }\n        }\n      }\n    }\n\n    return { success: false }\n  }\n\n  /**\n   * 获取远程文件的 SHA\n   */\n  private async getRemoteSha(path: string): Promise<string | undefined> {\n    try {\n      const info = await getRemoteFileInfo(path)\n      return info.sha\n    } catch {\n      return undefined\n    }\n  }\n\n  /**\n   * 强制推送文件到远程（忽略 SHA 不匹配）\n   * 用于用户确认后强制覆盖远程文件\n   */\n  async forcePush(path: string): Promise<{ success: boolean; sha?: string }> {\n    try {\n      const store = await Store.load('store.json')\n      const provider = (await store.get<string>('primaryBackupMethod') || 'github') as 'gitee' | 'github' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n      const repo = (provider !== 's3' && provider !== 'webdav') ? await getSyncRepoName(provider) : undefined\n\n      // 从磁盘读取最新内容\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(path)\n      const content = workspace.isCustom\n        ? await readTextFile(pathOptions.path)\n        : await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n\n      // 生成提交信息\n      const commitMessage = await this.generateCommitMessage(path, content)\n\n      let success = false\n      let uploadedSha: string | undefined\n\n      switch (provider) {\n        case 'github': {\n          const githubModule = await import('@/lib/sync/github') as any\n          // 强制上传：不带 sha 参数\n          const result = await githubModule.uploadFile({\n            ext: path.split('.').pop() || 'md',\n            file: content,\n            filename: path.split('/').pop() || path,\n            sha: undefined, // 强制上传，不带 sha\n            message: commitMessage,\n            repo,\n            path\n          })\n          if (result && result.data) {\n            success = true\n            uploadedSha = result?.data?.content?.sha\n          }\n          break\n        }\n        case 'gitee': {\n          const giteeModule = await import('@/lib/sync/gitee') as any\n          const result = await giteeModule.uploadFile({\n            ext: path.split('.').pop() || 'md',\n            file: content,\n            filename: path.split('/').pop() || path,\n            sha: undefined, // 强制上传\n            message: commitMessage,\n            repo,\n            path\n          })\n          if (result && result.data) {\n            success = true\n            // Gitee API 返回的是 result.data.content.sha\n            uploadedSha = result?.data?.content?.sha\n          }\n          break\n        }\n        case 'gitlab': {\n          const gitlabModule = await import('@/lib/sync/gitlab') as any\n          await gitlabModule.uploadFile({\n            file: content,\n            filename: path.split('/').pop() || path,\n            sha: undefined,\n            message: commitMessage,\n            repo,\n            path\n          })\n          success = true\n          uploadedSha = await this.getRemoteSha(path)\n          break\n        }\n        case 'gitea': {\n          const giteaModule = await import('@/lib/sync/gitea') as any\n          await giteaModule.uploadFile({\n            file: content,\n            filename: path.split('/').pop() || path,\n            sha: undefined,\n            message: commitMessage,\n            repo,\n            path\n          })\n          success = true\n          uploadedSha = await this.getRemoteSha(path)\n          break\n        }\n        case 's3': {\n          const s3Module = await import('@/lib/sync/s3') as any\n          const s3Config = await getS3Config()\n          if (!s3Config) {\n            console.warn('[SyncPushQueue] S3 未配置')\n            emitter.emit('sync-push-completed', { path, success: false })\n            return { success: false }\n          }\n\n          // 获取代理配置\n          const proxy = await getProxyConfig()\n\n          // S3 强制推送：直接上传，不检查 ETag\n          const result = await s3Module.s3Upload(s3Config, path, content, proxy)\n          if (result && result.etag) {\n            success = true\n            uploadedSha = result.etag\n            // 更新本地记录的 ETag\n            useSyncStore.getState().updateS3FileEtag(path, result.etag)\n          }\n          break\n        }\n        case 'webdav': {\n          const webdavModule = await import('@/lib/sync/webdav') as any\n          const webdavConfig = await getWebDAVConfig()\n          if (!webdavConfig) {\n            console.warn('[SyncPushQueue] WebDAV 未配置')\n            emitter.emit('sync-push-completed', { path, success: false })\n            return { success: false }\n          }\n\n          // 获取代理配置\n          const proxy = await getProxyConfig()\n\n          // WebDAV 强制推送：直接上传，不检查 ETag\n          const result = await webdavModule.webdavUpload(webdavConfig, path, content, proxy)\n          if (result && result.etag) {\n            success = true\n            uploadedSha = result.etag\n            // 更新本地记录的 ETag\n            useSyncStore.getState().updateWebDAVFileEtag(path, result.etag)\n          }\n          break\n        }\n      }\n\n      if (success) {\n        // 保存新的 SHA\n        if (uploadedSha) {\n          await setLocalRecordedSha(path, uploadedSha)\n        }\n        emitter.emit('sync-push-completed', { path, success: true, sha: uploadedSha })\n        return { success: true, sha: uploadedSha }\n      } else {\n        emitter.emit('sync-push-completed', { path, success: false })\n        return { success: false }\n      }\n    } catch (error) {\n      console.error('[SyncPushQueue] 强制推送失败:', error)\n      emitter.emit('sync-push-completed', { path, success: false })\n      return { success: false }\n    }\n  }\n\n  /**\n   * 生成 AI 提交信息\n   */\n  private async generateCommitMessage(path: string, content: string): Promise<string> {\n    try {\n      const { fetchAi } = await import('@/lib/ai/chat')\n      const prompt = `请为以下文档内容生成一个简洁的 Git 提交信息（不超过 50 个字符）：\n\n${content.slice(0, 1000)}${content.length > 1000 ? '...' : ''}\n\n直接返回提交信息，不需要任何解释或格式。`\n      const message = await fetchAi(prompt, 'commitModel')\n      return message.trim().slice(0, 50) || `Update ${path}`\n    } catch {\n      return `Update ${path}`\n    }\n  }\n\n  /**\n   * 清空队列\n   */\n  clear() {\n    this.queue = []\n    if (this.debounceTimer) {\n      clearTimeout(this.debounceTimer)\n      this.debounceTimer = null\n    }\n  }\n}\n\n// 单例实例\nlet syncPushQueue: SyncPushQueue | null = null\n\nexport function getSyncPushQueue(): SyncPushQueue {\n  if (!syncPushQueue) {\n    syncPushQueue = new SyncPushQueue()\n    syncPushQueue.init() // 确保只初始化一次事件监听器\n  }\n  return syncPushQueue\n}\n\nexport default SyncPushQueue\n"
  },
  {
    "path": "src/lib/sync/webdav.ts",
    "content": "import { fetch, Proxy } from '@tauri-apps/plugin-http'\nimport { WebDAVConfig } from '@/types/sync'\n\n/**\n * WebDAV 同步核心模块\n * 支持群晖、QNAP、Nextcloud 等 WebDAV 协议存储\n */\n\n/**\n * 构建 Basic Auth 头\n */\nfunction buildAuthHeader(username: string, password: string): string {\n  return `Basic ${btoa(`${username}:${password}`)}`\n}\n\n/**\n * 构建 WebDAV URL\n */\nfunction buildWebDAVUrl(config: WebDAVConfig, key: string): string {\n  const baseUrl = config.url.replace(/\\/$/, '')\n  const prefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n  const fullKey = prefix ? `${prefix}/${key}` : key\n  return `${baseUrl}/${fullKey}`\n}\n\n/**\n * 测试 WebDAV 连接\n */\nexport async function testWebDAVConnection(config: WebDAVConfig, proxy?: Proxy): Promise<boolean> {\n  try {\n    const baseUrl = config.url.replace(/\\/$/, '')\n    const response = await fetch(baseUrl, {\n      method: 'PROPFIND',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password),\n        'Depth': '0'\n      },\n      proxy\n    })\n\n    return response.status === 207  // 207 Multi-Status 表示成功\n  } catch (error) {\n    console.error('WebDAV connection test failed:', error)\n    return false\n  }\n}\n\n/**\n * 创建所有父目录\n */\nasync function ensureParentDirsExist(\n  config: WebDAVConfig,\n  key: string,\n  proxy?: Proxy\n): Promise<boolean> {\n  const pathPrefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n\n  // 首先确保 pathPrefix 目录存在\n  if (pathPrefix) {\n    // 直接用 baseUrl + pathPrefix 创建目录，不经过 webdavMkcol（它会重复添加 pathPrefix）\n    const baseUrl = config.url.replace(/\\/$/, '')\n    const mkcolUrl = `${baseUrl}/${pathPrefix}`\n\n    await fetch(mkcolUrl, {\n      method: 'MKCOL',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password)\n      }\n    })\n  }\n\n  const parts = key.split('/').filter(p => p)\n  // 构建所有可能的父目录路径\n  for (let i = 1; i < parts.length; i++) {\n    const parentPath = parts.slice(0, i).join('/')\n    await webdavMkcol(config, parentPath, proxy)\n  }\n  return true\n}\n\n/**\n * 上传文件到 WebDAV\n */\nexport async function webdavUpload(\n  config: WebDAVConfig,\n  key: string,\n  content: string,\n  proxy?: Proxy\n): Promise<{ etag: string } | null> {\n  try {\n    // 先确保父目录存在\n    await ensureParentDirsExist(config, key, proxy)\n\n    const url = buildWebDAVUrl(config, key)\n    const contentBytes = new TextEncoder().encode(content)\n\n    const response = await fetch(url, {\n      method: 'PUT',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password),\n        'Content-Type': 'text/markdown; charset=utf-8',\n        'Content-Length': contentBytes.byteLength.toString()\n      },\n      body: contentBytes,\n      proxy\n    })\n\n    if (response.status === 201 || response.status === 204) {\n      const etag = response.headers.get('ETag') || ''\n      return { etag }\n    } else {\n      const errorText = await response.text()\n      console.error('WebDAV Upload failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('WebDAV upload error:', error)\n    return null\n  }\n}\n\n/**\n * 从 WebDAV 下载文件\n */\nexport async function webdavDownload(\n  config: WebDAVConfig,\n  key: string,\n  proxy?: Proxy\n): Promise<{ content: string; etag: string; lastModified: string } | null> {\n  try {\n    const url = buildWebDAVUrl(config, key)\n\n    const response = await fetch(url, {\n      method: 'GET',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password)\n      },\n      proxy\n    })\n\n    if (response.status === 200) {\n      const content = await response.text()\n      const etag = response.headers.get('ETag') || ''\n      const lastModified = response.headers.get('Last-Modified') || ''\n\n      return { content, etag, lastModified }\n    } else if (response.status === 404) {\n      return null\n    } else {\n      const errorText = await response.text()\n      console.error('WebDAV Download failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('WebDAV download error:', error)\n    return null\n  }\n}\n\n/**\n * 删除 WebDAV 文件\n */\nexport async function webdavDelete(config: WebDAVConfig, key: string, proxy?: Proxy): Promise<boolean> {\n  try {\n    const url = buildWebDAVUrl(config, key)\n\n    const response = await fetch(url, {\n      method: 'DELETE',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password)\n      },\n      proxy\n    })\n\n    return response.status === 204 || response.status === 200\n  } catch (error) {\n    console.error('WebDAV delete error:', error)\n    return false\n  }\n}\n\n/**\n * 获取文件信息（HEAD 请求）\n */\nexport async function webdavHeadObject(\n  config: WebDAVConfig,\n  key: string,\n  proxy?: Proxy\n): Promise<{ etag: string; lastModified: string } | null> {\n  try {\n    const url = buildWebDAVUrl(config, key)\n\n    const response = await fetch(url, {\n      method: 'HEAD',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password)\n      },\n      proxy\n    })\n\n    if (response.status === 200) {\n      const etag = response.headers.get('ETag') || ''\n      const lastModified = response.headers.get('Last-Modified') || ''\n\n      return { etag, lastModified }\n    } else if (response.status === 404 || response.status === 409) {\n      // 文件不存在，返回 null\n      return null\n    } else {\n      const errorText = await response.text()\n      console.error('WebDAV HeadObject failed:', response.status, errorText)\n      return null\n    }\n  } catch (error) {\n    console.error('WebDAV head error:', error)\n    return null\n  }\n}\n\n/**\n * 列出 WebDAV 文件\n */\nexport async function webdavListObjects(\n  config: WebDAVConfig,\n  prefix: string,\n  proxy?: Proxy\n): Promise<Array<{ key: string; etag: string; lastModified: string; size: number }>> {\n  try {\n    const baseUrl = config.url.replace(/\\/$/, '')\n    const pathPrefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n    // 不要尾随斜杠\n    const fullPrefix = pathPrefix ? (prefix ? `${pathPrefix}/${prefix}` : pathPrefix) : prefix\n\n    const response = await fetch(`${baseUrl}/${fullPrefix}`, {\n      method: 'PROPFIND',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password),\n        'Depth': '1'\n      },\n      proxy\n    })\n\n    if (response.status === 207) {\n      const text = await response.text()\n      return parsePropfindResponse(text, fullPrefix)\n    } else if (response.status === 404 || response.status === 409) {\n      // 目录不存在是正常情况，不需要打印错误日志\n      return []\n    } else {\n      const errorText = await response.text()\n      console.error('WebDAV ListObjects failed:', response.status, errorText)\n      return []\n    }\n  } catch (error) {\n    console.error('WebDAV list error:', error)\n    return []\n  }\n}\n\n/**\n * 解析 PROPFIND 响应 XML\n */\nfunction parsePropfindResponse(\n  xml: string,\n  prefix: string\n): Array<{ key: string; etag: string; lastModified: string; size: number }> {\n  const results: Array<{ key: string; etag: string; lastModified: string; size: number }> = []\n\n  try {\n    // 使用正则解析 XML 响应\n    // 提取所有 response 元素\n    const responseRegex = /<d:response>([\\s\\S]*?)<\\/d:response>/g\n    let match\n\n    while ((match = responseRegex.exec(xml)) !== null) {\n      const responseContent = match[1]\n\n      // 提取 href\n      const hrefMatch = /<d:href>([^<]+)<\\/d:href>/.exec(responseContent)\n      // 提取 getetag\n      const etagMatch = /<d:getetag>([^<]+)<\\/d:getetag>/.exec(responseContent)\n      // 提取 getlastmodified\n      const lastModMatch = /<d:getlastmodified>([^<]+)<\\/d:getlastmodified>/.exec(responseContent)\n      // 提取 getcontentlength\n      const sizeMatch = /<d:getcontentlength>([^<]+)<\\/d:getcontentlength>/.exec(responseContent)\n\n      if (hrefMatch) {\n        let href = hrefMatch[1]\n\n        // 坚果云返回的 href 包含 /dav/ 前缀，需要移除\n        if (href.startsWith('/dav/')) {\n          href = href.substring(5) // 移除 /dav/\n        }\n\n        // 跳过根目录本身\n        if (href === `${prefix}/` || href === prefix || href.endsWith('/')) {\n          // 这是一个目录，跳过文件列表中的目录\n          continue\n        }\n\n        // 移除前缀，还原相对路径\n        if (prefix && href.startsWith(`${prefix}/`)) {\n          href = href.substring(`${prefix}/`.length)\n        } else if (prefix && href.startsWith(prefix)) {\n          href = href.substring(prefix.length)\n        }\n\n        // 移除开头的斜杠\n        href = href.replace(/^\\/+/, '')\n\n        // URL 解码\n        try {\n          href = decodeURIComponent(href)\n        } catch {\n          // 解码失败保持原样\n        }\n\n        results.push({\n          key: href,\n          etag: etagMatch ? etagMatch[1].replace(/\"/g, '') : '',\n          lastModified: lastModMatch ? lastModMatch[1] : '',\n          size: sizeMatch ? parseInt(sizeMatch[1], 10) : 0\n        })\n      }\n    }\n  } catch (error) {\n    console.error('Error parsing PROPFIND response:', error)\n  }\n\n  return results\n}\n\n/**\n * 创建目录\n */\nexport async function webdavMkcol(\n  config: WebDAVConfig,\n  path: string,\n  proxy?: Proxy\n): Promise<boolean> {\n  try {\n    const baseUrl = config.url.replace(/\\/$/, '')\n    const pathPrefix = config.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n    const fullPath = pathPrefix ? `${pathPrefix}/${path}` : path\n\n    const response = await fetch(`${baseUrl}/${fullPath}`, {\n      method: 'MKCOL',\n      headers: {\n        'Authorization': buildAuthHeader(config.username, config.password)\n      },\n      proxy\n    })\n\n    // 201 表示创建成功，405 表示已存在\n    return response.status === 201 || response.status === 405\n  } catch (error) {\n    console.error('WebDAV mkcol error:', error)\n    return false\n  }\n}\n"
  },
  {
    "path": "src/lib/template-range-utils.ts",
    "content": "import { GenTemplateRange } from '@/stores/setting';\n\n/**\n * 获取模板范围的国际化标签\n * @param range 模板范围枚举值\n * @param t 翻译函数\n * @returns 国际化后的标签文本\n */\nexport function getTemplateRangeLabel(range: GenTemplateRange, t: (key: string) => string): string {\n  const keyMap = {\n    [GenTemplateRange.All]: 'settings.template.range.all',\n    [GenTemplateRange.Today]: 'settings.template.range.today',\n    [GenTemplateRange.Week]: 'settings.template.range.week',\n    [GenTemplateRange.Month]: 'settings.template.range.month',\n    [GenTemplateRange.ThreeMonth]: 'settings.template.range.threeMonth',\n    [GenTemplateRange.Year]: 'settings.template.range.year',\n  };\n  if (!Object.values(GenTemplateRange).includes(range)) {\n    return t('settings.template.range.all');\n  }\n  \n  return t(keyMap[range]);\n}\n\n/**\n * 获取所有模板范围选项的国际化标签\n * @param t 翻译函数\n * @returns 包含值和标签的选项数组\n */\nexport function getTemplateRangeOptions(t: (key: string) => string) {\n  return Object.values(GenTemplateRange).map(value => ({\n    value,\n    label: getTemplateRangeLabel(value, t)\n  }));\n}\n"
  },
  {
    "path": "src/lib/theme-utils.ts",
    "content": "import { CustomThemeColors, HSLValue } from '@/types/theme'\n\n/**\n * 将 HSL 值转换为 CSS 变量格式\n */\nfunction hslToCssValue(hsl: HSLValue): string {\n  const [h, s, l] = hsl\n  return `${h} ${s}% ${l}%`\n}\n\n/**\n * 应用自定义主题颜色到 DOM\n * 这个函数会同时应用亮色和暗色主题的自定义颜色\n * 暗色主题的颜色通过设置在 .dark 类上的样式来实现\n */\nexport function applyThemeColors(colors: CustomThemeColors): void {\n  const root = document.documentElement\n\n  // 获取或创建用于暗色主题自定义颜色的 style 标签\n  let darkStyleTag = document.getElementById('custom-dark-theme')\n  if (!darkStyleTag) {\n    darkStyleTag = document.createElement('style')\n    darkStyleTag.id = 'custom-dark-theme'\n    document.head.appendChild(darkStyleTag)\n  }\n\n  // 构建暗色主题的 CSS 规则\n  let darkCssRules = '.dark {\\n'\n\n  // 应用亮色主题的自定义颜色到 :root（内联样式）\n  Object.entries(colors.light).forEach(([key, value]) => {\n    const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`\n    if (value) {\n      root.style.setProperty(cssVar, hslToCssValue(value))\n    } else {\n      // 如果值为 null，移除自定义值（恢复默认）\n      root.style.removeProperty(cssVar)\n    }\n  })\n\n  // 构建暗色主题的 CSS 规则\n  Object.entries(colors.dark).forEach(([key, value]) => {\n    const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`\n    if (value) {\n      darkCssRules += `  ${cssVar}: ${hslToCssValue(value)};\\n`\n    }\n    // 如果值为 null，不添加到规则中，让 CSS 默认值生效\n  })\n\n  darkCssRules += '}'\n\n  // 更新暗色主题的样式\n  darkStyleTag.textContent = darkCssRules\n}\n\n/**\n * 移除所有自定义主题颜色\n */\nexport function removeThemeColors(): void {\n  const root = document.documentElement\n\n  // 移除 :root 上的所有自定义颜色变量\n  const lightVars = [\n    'background', 'foreground', 'card', 'cardForeground',\n    'primary', 'primaryForeground', 'secondary', 'secondaryForeground',\n    'third', 'thirdForeground',\n    'muted', 'mutedForeground', 'accent', 'accentForeground', 'border',\n    'shadow'\n  ]\n\n  lightVars.forEach(key => {\n    const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`\n    root.style.removeProperty(cssVar)\n  })\n\n  // 移除暗色主题的样式标签\n  const darkStyleTag = document.getElementById('custom-dark-theme')\n  if (darkStyleTag) {\n    darkStyleTag.remove()\n  }\n}\n\n/**\n * 将颜色转换为 HSL 格式\n */\nexport function hexToHsl(hex: string): HSLValue | null {\n  // 移除 # 号\n  hex = hex.replace('#', '')\n\n  // 解析 RGB\n  let r = 0, g = 0, b = 0\n  if (hex.length === 3) {\n    r = parseInt(hex[0] + hex[0], 16)\n    g = parseInt(hex[1] + hex[1], 16)\n    b = parseInt(hex[2] + hex[2], 16)\n  } else if (hex.length === 6) {\n    r = parseInt(hex.substring(0, 2), 16)\n    g = parseInt(hex.substring(2, 4), 16)\n    b = parseInt(hex.substring(4, 6), 16)\n  } else {\n    return null\n  }\n\n  // 转换为 HSL\n  r /= 255\n  g /= 255\n  b /= 255\n\n  const max = Math.max(r, g, b)\n  const min = Math.min(r, g, b)\n  let h = 0, s = 0\n  const l = (max + min) / 2\n\n  if (max !== min) {\n    const d = max - min\n    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)\n\n    switch (max) {\n      case r:\n        h = ((g - b) / d + (g < b ? 6 : 0)) / 6\n        break\n      case g:\n        h = ((b - r) / d + 2) / 6\n        break\n      case b:\n        h = ((r - g) / d + 4) / 6\n        break\n    }\n  }\n\n  return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]\n}\n\n/**\n * 将 HSL 格式转换为 Hex\n */\nexport function hslToHex(hsl: HSLValue): string {\n  const [h, s, l] = hsl\n\n  const sNormalized = s / 100\n  const lNormalized = l / 100\n\n  const c = (1 - Math.abs(2 * lNormalized - 1)) * sNormalized\n  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))\n  const m = lNormalized - c / 2\n\n  let r = 0, g = 0, b = 0\n\n  if (h >= 0 && h < 60) {\n    r = c\n    g = x\n    b = 0\n  } else if (h >= 60 && h < 120) {\n    r = x\n    g = c\n    b = 0\n  } else if (h >= 120 && h < 180) {\n    r = 0\n    g = c\n    b = x\n  } else if (h >= 180 && h < 240) {\n    r = 0\n    g = x\n    b = c\n  } else if (h >= 240 && h < 300) {\n    r = x\n    g = 0\n    b = c\n  } else if (h >= 300 && h < 360) {\n    r = c\n    g = 0\n    b = x\n  }\n\n  const rHex = Math.round((r + m) * 255).toString(16).padStart(2, '0')\n  const gHex = Math.round((g + m) * 255).toString(16).padStart(2, '0')\n  const bHex = Math.round((b + m) * 255).toString(16).padStart(2, '0')\n\n  return `#${rHex}${gHex}${bHex}`\n}\n"
  },
  {
    "path": "src/lib/toolbar-shortcuts.ts",
    "content": "type Platform = 'macos' | 'windows' | 'linux' | 'unknown'\n\ninterface ToolbarShortcutEventLike {\n  key: string\n  metaKey?: boolean\n  altKey?: boolean\n  ctrlKey?: boolean\n  shiftKey?: boolean\n  repeat?: boolean\n}\n\nexport function resolveToolbarShortcutIndex(\n  event: ToolbarShortcutEventLike,\n  platform: Platform,\n  enabledItemCount: number,\n): number | null {\n  if (platform === 'unknown' || enabledItemCount <= 0 || event.repeat) {\n    return null\n  }\n\n  const usesPlatformModifier = platform === 'macos'\n    ? Boolean(event.metaKey) && !event.ctrlKey && !event.altKey\n    : Boolean(event.altKey) && !event.ctrlKey && !event.metaKey\n\n  if (!usesPlatformModifier || event.shiftKey) {\n    return null\n  }\n\n  const shortcutNumber = Number.parseInt(event.key, 10)\n  if (!Number.isInteger(shortcutNumber) || shortcutNumber < 1 || shortcutNumber > enabledItemCount) {\n    return null\n  }\n\n  return shortcutNumber - 1\n}\n"
  },
  {
    "path": "src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\nimport { convertFileSrc } from \"@tauri-apps/api/core\";\nimport { appDataDir } from '@tauri-apps/api/path';\nimport { getWorkspacePath } from \"./workspace\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n\nexport async function convertImage(path: string) {\n  const appDataDirPath = await appDataDir()\n  const imagePath = appDataDirPath + path\n  return convertFileSrc(imagePath)\n}\n\nexport async function convertImageByWorkspace(path: string) {\n  const workspace = await getWorkspacePath()\n  let fullPath: string\n  if (workspace.isCustom) {\n    fullPath = `${workspace.path}/${path}`\n  } else {\n    fullPath = `${await appDataDir()}/article/${path}`\n  }\n  return convertFileSrc(fullPath)\n}\n\nexport function convertBytesToSize(bytes: number) {\n  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n  if (bytes === 0) {\n    return '0 Bytes';\n  }\n  const i = Math.floor(Math.log(bytes) / Math.log(1024))\n  return Math.round(bytes / Math.pow(1024, i)) + ' ' + sizes[i];\n}\n\nexport function arrayBuffer2String(buffer: ArrayBuffer) {\n  const decoder = new TextDecoder('iso-8859-1');\n  return decoder.decode(buffer);\n}\n\nexport function scrollToBottom() {\n  const md = document.querySelector('#chats-wrapper')\n  if (md) {\n    // 使用 requestAnimationFrame 确保在下一帧渲染后滚动\n    requestAnimationFrame(() => {\n      // 再使用 setTimeout 确保复杂内容（如代码块）已完全渲染\n      setTimeout(() => {\n        md.scroll(0, md.scrollHeight)\n      }, 0)\n    })\n  }\n}"
  },
  {
    "path": "src/lib/vector-document-key.js",
    "content": "export function getVectorDocumentKey(filePath) {\n  return filePath.replace(/\\\\/g, '/').trim()\n}\n\nexport function buildVectorIndexedMap(vectorDocuments) {\n  const indexedMap = new Map()\n\n  for (const doc of vectorDocuments) {\n    const currentTimestamp = indexedMap.get(doc.filename)\n    if (currentTimestamp === undefined || doc.updated_at > currentTimestamp) {\n      indexedMap.set(doc.filename, doc.updated_at)\n    }\n  }\n\n  return indexedMap\n}\n"
  },
  {
    "path": "src/lib/vector-document-key.spec.mjs",
    "content": "import test from 'node:test'\nimport assert from 'node:assert/strict'\n\nimport { buildVectorIndexedMap, getVectorDocumentKey } from './vector-document-key.js'\n\ntest('uses normalized relative file paths as vector document keys', () => {\n  assert.equal(getVectorDocumentKey('notes\\\\daily\\\\todo.md'), 'notes/daily/todo.md')\n})\n\ntest('keeps same-name files in different folders as separate vector keys', () => {\n  const indexedMap = buildVectorIndexedMap([\n    { filename: 'project-a/README.md', updated_at: 100 },\n    { filename: 'project-b/README.md', updated_at: 200 },\n  ])\n\n  assert.equal(indexedMap.size, 2)\n  assert.equal(indexedMap.get('project-a/README.md'), 100)\n  assert.equal(indexedMap.get('project-b/README.md'), 200)\n})\n"
  },
  {
    "path": "src/lib/workspace.ts",
    "content": "import { BaseDirectory } from '@tauri-apps/plugin-fs'\nimport { appDataDir, join } from '@tauri-apps/api/path'\nimport { Store } from '@tauri-apps/plugin-store'\n\n/**\n * 获取当前工作区路径\n * 如果设置了自定义工作区，则返回自定义路径\n * 否则返回默认的 AppData/article 路径\n */\nexport async function getWorkspacePath(): Promise<{ path: string, isCustom: boolean }> {\n  // 查询本地存储\n  const store = await Store.load('store.json')\n  const workspacePath = await store.get<string>('workspacePath')\n\n  // 如果设置了自定义工作区路径，则使用自定义路径\n  if (workspacePath) {\n    return {\n      path: workspacePath,\n      isCustom: true\n    }\n  }\n\n  // 否则使用默认路径\n  return {\n    path: 'article',\n    isCustom: false\n  }\n}\n\n/**\n * 获取文件的完整路径选项\n * @param relativePath 相对于工作区的路径\n * @returns 包含文件路径和baseDir的选项\n */\nexport async function getFilePathOptions(relativePath: string): Promise<{ path: string, baseDir?: BaseDirectory }> {\n  const workspace = await getWorkspacePath()\n\n  if (workspace.isCustom) {\n    // 对于自定义工作区，返回绝对路径，不设置baseDir\n    const fullPath = await join(workspace.path, relativePath)\n    return { path: fullPath }\n  } else {\n    // 对于默认工作区，使用AppData作为baseDir\n    const resolvedPath = `article/${relativePath}`\n    return {\n      path: resolvedPath,\n      baseDir: BaseDirectory.AppData\n    }\n  }\n}\n\n/**\n * 获取默认 AppData/article 下的绝对路径\n * 主要用于 skills/runtime、outputs 等特殊目录，避免某些 baseDir 写入限制\n */\nexport async function getDefaultArticleAbsolutePath(relativePath: string): Promise<string> {\n  const normalized = relativePath\n    .trim()\n    .replace(/\\\\/g, '/')\n    .replace(/^\\.?\\//, '')\n    .replace(/^article\\//, '')\n\n  const appDataPath = await appDataDir()\n  return await join(appDataPath, 'article', normalized)\n}\n\n/**\n * 获取通用文件路径选项\n * 不限于article目录，可处理任意AppData下的路径\n * @param path 原始路径，可能包含或不包含目录前缀\n * @param prefix 可选的目录前缀，如'article'、'image'等\n * @returns 包含文件路径和baseDir的选项\n */\nexport async function getGenericPathOptions(path: string, prefix?: string): Promise<{ path: string, baseDir?: BaseDirectory }> {\n  const workspace = await getWorkspacePath()\n  \n  if (workspace.isCustom) {\n    // 对于自定义工作区，返回基于自定义工作区的绝对路径\n    let fullPath = workspace.path\n    \n    // 如果指定了prefix，且path不以prefix开头，则添加prefix\n    if (prefix && !path.startsWith(`${prefix}/`) && !path.startsWith(prefix)) {\n      fullPath = await join(fullPath, prefix || '', path)\n    } else {\n      fullPath = await join(fullPath, path)\n    }\n    \n    return { path: fullPath }\n  } else {\n    // 对于默认工作区，使用AppData作为baseDir\n    // 如果指定了prefix且path不以prefix开头，则添加prefix/\n    if (prefix && !path.startsWith(`${prefix}/`) && !path.startsWith(prefix)) {\n      return {\n        path: `${prefix}/${path}`,\n        baseDir: BaseDirectory.AppData\n      }\n    }\n    \n    return { \n      path: path, \n      baseDir: BaseDirectory.AppData \n    }\n  }\n}\n\n/**\n * 将任何路径转换为相对于工作区的路径\n * @param path 原始路径\n * @returns 相对于工作区的路径\n */\nexport async function toWorkspaceRelativePath(path: string): Promise<string> {\n  const workspace = await getWorkspacePath()\n  \n  const defaultDirRegex = /^(article[\\\\\\/])/\n  // 如果是默认工作区，移除\"article/\"前缀\n  if (!workspace.isCustom && defaultDirRegex.test(path)) {\n    return path.replace(/article[\\\\\\/]/g, '')\n  }\n  \n  // 如果是自定义工作区，移除工作区路径前缀\n  if (workspace.isCustom && path.startsWith(workspace.path)) {\n    // 确保路径分隔符处理正确\n    const relativePath = path.substring(workspace.path.length)\n    // 移除开头的斜杠（如果有）\n    return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath\n  }\n  \n  // 如果路径已经是相对路径，直接返回\n  return path\n}\n\n/**\n * 规范化相对于工作区的路径\n * - 自定义工作区: 保持相对路径原样\n * - 默认工作区(article): 自动移除误传入的 article/ 前缀\n */\nexport async function normalizeWorkspaceRelativePath(relativePath: string): Promise<string> {\n  const workspace = await getWorkspacePath()\n  const normalized = relativePath\n    .trim()\n    .replace(/\\\\/g, '/')\n    .replace(/^\\.?\\//, '')\n    .replace(/\\/+/g, '/')\n\n  if (workspace.isCustom) {\n    return normalized\n  }\n\n  if (normalized === 'article') {\n    return ''\n  }\n\n  return normalized.replace(/^article\\//, '')\n}\n"
  },
  {
    "path": "src/stores/article.ts",
    "content": "import { getFiles as getGithubFiles } from '@/lib/sync/github'\nimport { GithubContent } from '@/lib/sync/github.types'\nimport { getFiles as getGiteeFiles } from '@/lib/sync/gitee'\nimport { getFiles as getGiteaFiles } from '@/lib/sync/gitea'\nimport { getFiles as getGitlabFiles } from '@/lib/sync/gitlab'\nimport { GiteeFile } from '@/lib/sync/gitee'\nimport { GiteaDirectoryItem } from '@/lib/sync/gitea.types'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { s3ListObjects } from '@/lib/sync/s3'\nimport { webdavListObjects } from '@/lib/sync/webdav'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\nimport { hasNetworkConnection, ensureDirectoryExists, pullRemoteFile, saveLocalFile } from '@/lib/sync/auto-sync'\nimport { syncOnOpen } from '@/lib/sync/sync-manager'\nimport { sanitizeFilePath, hasInvalidFileNameChars } from '@/lib/sync/filename-utils'\nimport { getCurrentFolder, computedParentPath } from '@/lib/path'\nimport useVectorStore from './vector'\nimport { join, appDataDir } from '@tauri-apps/api/path'\nimport { BaseDirectory, DirEntry, exists, mkdir, readDir, readTextFile, writeTextFile, stat } from '@tauri-apps/plugin-fs'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { cloneDeep, uniq } from 'lodash-es'\nimport { create } from 'zustand'\nimport { getFilePathOptions, getWorkspacePath, toWorkspaceRelativePath } from '@/lib/workspace'\nimport emitter from '@/lib/emitter'\nimport { isSkillsFolder } from '@/lib/skills/utils'\nimport { buildVectorIndexedMap, getVectorDocumentKey } from '@/lib/vector-document-key'\n\n// 缓存 Store 实例，避免每次都重新加载\nlet storeInstance: Store | null = null\nasync function getStore(): Promise<Store> {\n  if (!storeInstance) {\n    storeInstance = await Store.load('store.json')\n  }\n  return storeInstance\n}\n\nexport type SortType = 'name' | 'created' | 'modified' | 'none'\nexport type SortDirection = 'asc' | 'desc'\n\nexport interface DirTree extends DirEntry {\n  children?: DirTree[]\n  parent?: DirTree\n  sha?: string\n  size?: number\n  isEditing?: boolean\n  isLocale: boolean\n  createdAt?: string\n  modifiedAt?: string\n  loading?: boolean  // 文件夹正在加载中\n  vectorCalcStatus?: 'idle' | 'calculating' | 'completed'  // 向量计算状态\n}\n\nexport interface Article {\n  article: string\n  path: string\n}\n\n// 查找文件夹节点\nexport const findFolderInTree = (path: string, tree: DirTree[]): DirTree | null => {\n  for (const item of tree) {\n    const itemPath = computedParentPath(item)\n    if (itemPath === path && item.isDirectory) {\n      return item\n    }\n    if (item.children && item.children.length > 0) {\n      const found = findFolderInTree(path, item.children)\n      if (found) return found\n    }\n  }\n  return null\n}\n\nfunction isLikelyFilePath(path: string): boolean {\n  const name = path.split('/').pop() || path\n  return name.includes('.')\n}\n\nfunction getFolderPathsToExpand(path: string): string[] {\n  const segments = path.split('/').filter(Boolean)\n  const folderSegments = isLikelyFilePath(path) ? segments.slice(0, -1) : segments\n\n  return folderSegments.map((_, index) => folderSegments.slice(0, index + 1).join('/'))\n}\n\nfunction createLocalTreeNode(name: string, isDirectory: boolean, parent?: DirTree): DirTree {\n  return {\n    name,\n    isDirectory,\n    isFile: !isDirectory,\n    isSymlink: false,\n    children: isDirectory ? [] : undefined,\n    parent,\n    isEditing: false,\n    isLocale: true,\n    sha: '',\n    createdAt: undefined,\n    modifiedAt: undefined,\n  }\n}\n\nfunction insertNodeIntoTree(tree: DirTree[], relativePath: string, isDirectory: boolean): boolean {\n  const parentPath = relativePath.split('/').slice(0, -1).join('/')\n  const name = relativePath.split('/').pop() || relativePath\n\n  if (!parentPath) {\n    if (tree.some(item => item.name === name)) {\n      return true\n    }\n    tree.unshift(createLocalTreeNode(name, isDirectory))\n    return true\n  }\n\n  const parentFolder = getCurrentFolder(parentPath, tree)\n  if (!parentFolder || !parentFolder.isDirectory) {\n    return false\n  }\n\n  if (!parentFolder.children) {\n    parentFolder.children = []\n  }\n\n  if (parentFolder.children.some(item => item.name === name)) {\n    return true\n  }\n\n  parentFolder.children.unshift(createLocalTreeNode(name, isDirectory, parentFolder))\n  return true\n}\n\nfunction removeNodeFromTree(tree: DirTree[], relativePath: string): DirTree | null {\n  const parentPath = relativePath.split('/').slice(0, -1).join('/')\n  const name = relativePath.split('/').pop() || relativePath\n\n  if (!parentPath) {\n    const index = tree.findIndex(item => item.name === name)\n    if (index === -1) {\n      return null\n    }\n    return tree.splice(index, 1)[0] || null\n  }\n\n  const parentFolder = getCurrentFolder(parentPath, tree)\n  if (!parentFolder?.children) {\n    return null\n  }\n\n  const index = parentFolder.children.findIndex(item => item.name === name)\n  if (index === -1) {\n    return null\n  }\n\n  return parentFolder.children.splice(index, 1)[0] || null\n}\n\nfunction attachNodeToTree(tree: DirTree[], relativePath: string, node: DirTree): boolean {\n  const parentPath = relativePath.split('/').slice(0, -1).join('/')\n  const name = relativePath.split('/').pop() || relativePath\n  node.name = name\n\n  if (!parentPath) {\n    node.parent = undefined\n    if (!tree.some(item => item.name === name)) {\n      tree.unshift(node)\n    }\n    return true\n  }\n\n  const parentFolder = getCurrentFolder(parentPath, tree)\n  if (!parentFolder || !parentFolder.isDirectory) {\n    return false\n  }\n\n  if (!parentFolder.children) {\n    parentFolder.children = []\n  }\n\n  node.parent = parentFolder\n  if (!parentFolder.children.some(item => item.name === name)) {\n    parentFolder.children.unshift(node)\n  }\n  return true\n}\n\ninterface NoteState {\n  loading: boolean\n  setLoading: (loading: boolean) => void\n\n  activeFilePath: string\n  setActiveFilePath: (name: string) => void\n\n  // 当前正在读取的文件路径，用于避免竞态条件\n  readFilePath: string\n  setReadFilePath: (path: string) => void\n\n  // Tabs for multi-file editing\n  openTabs: Array<{ id: string; path: string; name: string; isFolder: boolean }>\n  setOpenTabs: (tabs: Array<{ id: string; path: string; name: string; isFolder: boolean }>) => void\n  activeTabId: string\n  setActiveTabId: (id: string) => void\n  addTab: (tab: { id: string; path: string; name: string; isFolder: boolean }) => void\n  removeTab: (id: string) => void\n  cleanTabsByDeletedFile: (deletedPath: string) => Promise<void>\n  cleanTabsByDeletedFolder: (deletedFolderPath: string) => Promise<void>\n  clearTabs: () => void\n\n  matchPosition: number | null\n  setMatchPosition: (position: number | null) => void\n  pendingSearchKeyword: string\n  setPendingSearchKeyword: (keyword: string) => void\n\n  html2md: boolean\n  initHtml2md: () => Promise<void>\n  setHtml2md: (html2md: boolean) => Promise<void>\n\n  showCloudFiles: boolean\n  initShowCloudFiles: () => Promise<void>\n  setShowCloudFiles: (show: boolean) => Promise<void>\n\n  // Initialize tabs from store\n  initOpenTabs: () => Promise<void>\n\n  sortType: SortType\n  sortDirection: SortDirection\n  initSortSettings: () => Promise<void>\n  initEventListeners: () => void\n  setSortType: (sortType: SortType) => Promise<void>\n  setSortDirection: (direction: SortDirection) => Promise<void>\n  sortFileTree: (tree: DirTree[]) => DirTree[]\n  updateFileStats: (path: string, tree: DirTree[]) => Promise<DirTree[]>\n  loadFileStatsIfNeeded: () => Promise<void>\n\n  fileTree: DirTree[]\n  fileTreeLoading: boolean\n  setFileTree: (tree: DirTree[]) => void\n  addFile: (file: DirTree) => void\n  ensurePathExpanded: (path: string) => Promise<void>\n  insertLocalEntry: (relativePath: string, isDirectory: boolean) => boolean\n  removeLocalEntry: (relativePath: string) => boolean\n  moveLocalEntry: (oldPath: string, newPath: string) => boolean\n  syncOpenTabsForPathChange: (oldPath: string, newPath: string) => Promise<void>\n  loadFileTree: () => Promise<void>\n  loadRemoteSyncFiles: () => Promise<void>\n  loadCollapsibleFiles: (folderName: string) => Promise<void>\n  loadFolderRemoteFiles: (folderName: string) => Promise<void>\n  newFolder: () => void\n  newFile: () => void\n  newFileOnFolder: (path: string) => void\n  newFolderInFolder: (path: string) => void\n\n  collapsibleList: string[]\n  collapsibleListInitialized: boolean\n  initCollapsibleList: () => Promise<void>\n  setCollapsibleList: (name: string, value: boolean) => Promise<void>\n  expandAllFolders: () => Promise<void>\n  collapseAllFolders: () => Promise<void>\n  toggleAllFolders: () => Promise<void>\n  clearCollapsibleList: () => Promise<void>\n\n  currentArticle: string\n  isPulling: boolean // 新增：拉取状态\n  justPulledFile: boolean // 标记是否刚从远程拉取文件（用于避免立即推送）\n  skipSyncOnSave: boolean // 标记是否跳过同步（用于程序写入时）\n  aiGeneratingFilePath: string | null // 标记当前正在 AI 生成的文件路径\n  aiTerminateFn: (() => void) | null // AI 生成的终止函数\n  readArticle: (path: string, sha?: string, isLocale?: boolean, autoSync?: boolean) => Promise<void>\n  setCurrentArticle: (content: string) => void\n  setIsPulling: (pulling: boolean) => void\n  setJustPulledFile: (justPulled: boolean) => void\n  setSkipSyncOnSave: (skip: boolean) => void\n  setAiGeneratingFilePath: (path: string | null) => void\n  setAiTerminateFn: (fn: (() => void) | null) => void\n  saveCurrentArticle: (content: string) => Promise<void>\n  // 防抖保存相关\n  debounceSaveTimer: NodeJS.Timeout | null\n  pendingSaveContent: string | null\n  // 更新文件 sha 状态（推送成功后调用）\n  updateFileSha: (path: string, sha: string) => void\n\n  // 向量计算相关\n  vectorCalcTimer: NodeJS.Timeout | null\n  vectorCalcProgressInterval: NodeJS.Timeout | null\n  vectorCalcProgress: number\n  isVectorCalculating: boolean\n  lastEditTime: number\n  pendingVectorContent: { path: string; content: string } | null\n  scheduleVectorCalculation: (path: string, content: string) => void\n  executeVectorCalculation: () => Promise<void>\n  cancelVectorCalculation: () => void\n  triggerVectorCalculation: () => Promise<void> // 手动触发向量计算\n  // 向量索引状态\n  vectorIndexedFiles: Map<string, number> // 工作区相对路径 -> 向量索引时间戳\n  checkFileVectorIndexed: (filePath: string) => Promise<boolean>\n  clearFileVector: (filePath: string) => Promise<void>\n  initVectorIndexedFiles: () => Promise<void> // 初始化向量索引状态\n  // 向量计算状态更新\n  setVectorCalcStatus: (path: string, status: 'idle' | 'calculating' | 'completed') => void\n\n  allArticle: Article[]\n  loadAllArticle: () => Promise<void>\n}\n\nconst useArticleStore = create<NoteState>((set, get) => ({\n  loading: false,\n\n  // 防抖保存相关状态\n  debounceSaveTimer: null,\n  pendingSaveContent: null,\n\n  setLoading: (loading: boolean) => { set({ loading }) },\n\n  sortType: 'none',\n  sortDirection: 'asc',\n  initSortSettings: async () => {\n    const store = await getStore()\n    const sortType = await store.get<SortType>('sortType')\n    const sortDirection = await store.get<SortDirection>('sortDirection')\n    if (sortType) set({ sortType })\n    if (sortDirection) set({ sortDirection })\n\n    // 如果需要按时间排序，加载统计信息\n    if (sortType === 'created' || sortType === 'modified') {\n      await get().loadFileStatsIfNeeded()\n    }\n\n    // 初始化事件监听器\n    get().initEventListeners()\n  },\n\n  // 初始化事件监听器\n  initEventListeners: () => {\n    // 监听同步推送完成事件，更新文件树的 sha 状态\n    emitter.on('sync-push-completed', ((event: { path: string; success: boolean; sha?: string }) => {\n      const { path, success, sha } = event\n      if (success && sha) {\n        get().updateFileSha(path, sha)\n      }\n    }) as any)\n  },\n  setSortType: async (sortType: SortType) => {\n    set({ sortType })\n    const store = await getStore()\n    await store.set('sortType', sortType)\n    \n    // 如果需要按时间排序，先加载统计信息\n    if (sortType === 'created' || sortType === 'modified') {\n      await get().loadFileStatsIfNeeded()\n    }\n    \n    const currentTree = get().fileTree\n    const sortedTree = get().sortFileTree(currentTree)\n    set({ fileTree: sortedTree })\n  },\n  setSortDirection: async (direction: SortDirection) => {\n    set({ sortDirection: direction })\n    const store = await getStore()\n    await store.set('sortDirection', direction)\n    \n    // 如果当前是按时间排序，确保统计信息已加载\n    const sortType = get().sortType\n    if (sortType === 'created' || sortType === 'modified') {\n      await get().loadFileStatsIfNeeded()\n    }\n    \n    const currentTree = get().fileTree\n    const sortedTree = get().sortFileTree(currentTree)\n    set({ fileTree: sortedTree })\n  },\n  \n  sortFileTree: (tree: DirTree[]) => {\n    const sortType = get().sortType\n    const sortDirection = get().sortDirection\n\n    // 复制树结构，避免直接修改原始数据\n    const sortedTree = cloneDeep(tree)\n\n    // skills 文件夹始终置顶（在任何排序方式下，包括 sortType 为 'none' 时）\n    const sortFunction = (a: DirTree, b: DirTree) => {\n      const aIsSkills = a.isDirectory && isSkillsFolder(a.name)\n      const bIsSkills = b.isDirectory && isSkillsFolder(b.name)\n      if (aIsSkills && !bIsSkills) return -1\n      if (!aIsSkills && bIsSkills) return 1\n\n      // 如果排序类型为 'none'，在 skills 置顶后，文件夹在文件上方\n      if (sortType === 'none') {\n        if (a.isDirectory && !b.isDirectory) return -1\n        if (!a.isDirectory && b.isDirectory) return 1\n        return 0\n      }\n\n      // 文件夹始终在文件上方\n      if (a.isDirectory && !b.isDirectory) return -1\n      if (!a.isDirectory && b.isDirectory) return 1\n\n      // 同类型的进行排序\n      let result = 0\n      switch (sortType) {\n        case 'name':\n          result = a.name.localeCompare(b.name)\n          break\n        case 'created':\n          if (a.createdAt && b.createdAt) {\n            result = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()\n          } else {\n            result = a.name.localeCompare(b.name)\n          }\n          break\n        case 'modified':\n          if (a.modifiedAt && b.modifiedAt) {\n            result = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()\n          } else {\n            result = a.name.localeCompare(b.name)\n          }\n          break\n        default:\n          result = 0\n      }\n      return sortDirection === 'asc' ? result : -result\n    }\n\n    sortedTree.sort(sortFunction)\n\n    const sortChildren = (items: DirTree[]) => {\n      for (const item of items) {\n        if (item.children && item.children.length > 0) {\n          item.children.sort(sortFunction)\n          sortChildren(item.children)\n        }\n      }\n    }\n\n    sortChildren(sortedTree)\n    return sortedTree\n  },\n\n  activeFilePath: '',\n  setActiveFilePath: async (path: string) => {\n    // 切换文件时，先清空 currentArticle，避免内容覆盖\n    set({ currentArticle: '', activeFilePath: path })\n    const store = await getStore();\n    await store.set('activeFilePath', path)\n    // 触发事件，让推送队列重置计时器\n    emitter.emit('article-opened', { path })\n\n    // 触发读取文件内容（包括远程拉取）\n    // 需要确保是文件而不是文件夹\n    const fileName = path.split('/').pop() || ''\n    if (fileName && fileName.includes('.')) {\n      get().readArticle(path)\n    }\n  },\n\n  // Tabs initialization - load from store\n  openTabs: [],\n  activeTabId: '',\n  setOpenTabs: async (tabs) => {\n    set({ openTabs: tabs })\n    const store = await getStore();\n    await store.set('openTabs', tabs)\n  },\n  setActiveTabId: async (id) => {\n    set({ activeTabId: id })\n    const store = await getStore();\n    await store.set('activeTabId', id)\n  },\n  addTab: async (tab) => {\n    const currentTabs = get().openTabs\n    // Check if tab already exists\n    if (currentTabs.find(t => t.path === tab.path)) {\n      return\n    }\n    const newTabs = [...currentTabs, tab].slice(-10) // Limit to 10 tabs\n    set({ openTabs: newTabs, activeTabId: tab.id })\n    const store = await getStore();\n    await store.set('openTabs', newTabs)\n    await store.set('activeTabId', tab.id)\n  },\n  removeTab: async (id) => {\n    const currentTabs = get().openTabs\n    const newTabs = currentTabs.filter(t => t.id !== id)\n    set({ openTabs: newTabs })\n    const store = await getStore();\n    await store.set('openTabs', newTabs)\n  },\n\n  // 清理已被删除的文件对应的 tabs（根据路径匹配）\n  cleanTabsByDeletedFile: async (deletedPath: string) => {\n    const currentTabs = get().openTabs\n    const currentActiveTabId = get().activeTabId\n    const currentActiveFilePath = get().activeFilePath\n    const newTabs = currentTabs.filter(t => t.path !== deletedPath)\n\n    // 如果有标签页被移除，更新状态\n    if (newTabs.length !== currentTabs.length) {\n      // 如果删除的是当前活动的 tab，自动选择另一个 tab\n      const deletedTab = currentTabs.find(t => t.path === deletedPath)\n      let newActiveTabId = currentActiveTabId\n      let newActiveFilePath = currentActiveFilePath\n\n      if (deletedTab && currentActiveTabId === deletedTab.id && newTabs.length > 0) {\n        // 选择最后一个 tab\n        const targetTab = newTabs[newTabs.length - 1]\n        newActiveTabId = targetTab.id\n        newActiveFilePath = targetTab.path\n      } else if (deletedTab && currentActiveTabId === deletedTab.id) {\n        // 没有其他 tab 了\n        newActiveTabId = ''\n        newActiveFilePath = ''\n      }\n\n      set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '' })\n      const store = await getStore();\n      await store.set('openTabs', newTabs)\n      await store.set('activeTabId', newActiveTabId)\n      await store.set('activeFilePath', newActiveFilePath)\n    }\n  },\n\n  // 清理已被删除的文件夹对应的 tabs（清理该文件夹下所有文件的 tabs）\n  cleanTabsByDeletedFolder: async (deletedFolderPath: string) => {\n    const currentTabs = get().openTabs\n    const currentActiveTabId = get().activeTabId\n    const currentActiveFilePath = get().activeFilePath\n    const folderPrefix = deletedFolderPath.endsWith('/') ? deletedFolderPath : deletedFolderPath + '/'\n    const newTabs = currentTabs.filter(t => !t.path.startsWith(folderPrefix))\n\n    // 如果有标签页被移除，更新状态\n    if (newTabs.length !== currentTabs.length) {\n      // 如果删除的是当前活动的 tab，自动选择另一个 tab\n      const deletedTab = currentTabs.find(t => t.path.startsWith(folderPrefix))\n      let newActiveTabId = currentActiveTabId\n      let newActiveFilePath = currentActiveFilePath\n\n      if (deletedTab && currentActiveTabId === deletedTab.id && newTabs.length > 0) {\n        // 选择最后一个 tab\n        const targetTab = newTabs[newTabs.length - 1]\n        newActiveTabId = targetTab.id\n        newActiveFilePath = targetTab.path\n      } else if (deletedTab && currentActiveTabId === deletedTab.id) {\n        // 没有其他 tab 了\n        newActiveTabId = ''\n        newActiveFilePath = ''\n      }\n\n      set({ openTabs: newTabs, activeTabId: newActiveTabId, activeFilePath: newActiveFilePath, currentArticle: '' })\n      const store = await getStore();\n      await store.set('openTabs', newTabs)\n      await store.set('activeTabId', newActiveTabId)\n      await store.set('activeFilePath', newActiveFilePath)\n    }\n  },\n\n  clearTabs: async () => {\n    set({ openTabs: [], activeTabId: '' })\n    const store = await getStore();\n    await store.set('openTabs', [])\n    await store.set('activeTabId', '')\n  },\n\n  matchPosition: null,\n  setMatchPosition: (position: number | null) => {\n    set({ matchPosition: position })\n  },\n  pendingSearchKeyword: '',\n  setPendingSearchKeyword: (keyword: string) => {\n    set({ pendingSearchKeyword: keyword })\n  },\n\n  html2md: false,\n  initHtml2md: async () => {\n    const store = await getStore();\n    const res = await store.get<boolean>('html2md')\n    set({ html2md: res || false })\n  },\n  setHtml2md: async (html2md: boolean) => {\n    set({ html2md })\n    const store = await getStore();\n    store.set('html2md', html2md)\n  },\n\n  showCloudFiles: true,\n  initShowCloudFiles: async () => {\n    const store = await getStore();\n    const res = await store.get<boolean>('showCloudFiles')\n    set({ showCloudFiles: res ?? true })\n  },\n\n  // Initialize open tabs from store\n  initOpenTabs: async () => {\n    const store = await getStore();\n    const tabs = await store.get<Array<{ id: string; path: string; name: string; isFolder: boolean }>>('openTabs')\n    const activeTabId = await store.get<string>('activeTabId')\n    set({ openTabs: tabs || [], activeTabId: activeTabId || '' })\n  },\n  setShowCloudFiles: async (show: boolean) => {\n    set({ showCloudFiles: show })\n    const store = await getStore();\n    await store.set('showCloudFiles', show)\n  },\n\n  fileTree: [],\n  setFileTree: (tree: DirTree[]) => {\n    const sortedTree = get().sortFileTree(tree)\n    set({ fileTree: sortedTree })\n  },\n  addFile: (file: DirTree) => {\n    set({ fileTree: [file, ...get().fileTree] })\n  },\n  ensurePathExpanded: async (path: string) => {\n    const folderPaths = getFolderPathsToExpand(path)\n    if (folderPaths.length === 0) {\n      return\n    }\n\n    const collapsibleList = uniq([...get().collapsibleList, ...folderPaths])\n    const store = await getStore()\n    await store.set('collapsibleList', collapsibleList)\n    set({ collapsibleList })\n  },\n  insertLocalEntry: (relativePath: string, isDirectory: boolean) => {\n    const cacheTree = cloneDeep(get().fileTree)\n    const inserted = insertNodeIntoTree(cacheTree, relativePath, isDirectory)\n\n    if (!inserted) {\n      return false\n    }\n\n    get().setFileTree(cacheTree)\n    return true\n  },\n  removeLocalEntry: (relativePath: string) => {\n    const cacheTree = cloneDeep(get().fileTree)\n    const removed = removeNodeFromTree(cacheTree, relativePath)\n\n    if (!removed) {\n      return false\n    }\n\n    get().setFileTree(cacheTree)\n    return true\n  },\n  moveLocalEntry: (oldPath: string, newPath: string) => {\n    const cacheTree = cloneDeep(get().fileTree)\n    const removedNode = removeNodeFromTree(cacheTree, oldPath)\n\n    if (!removedNode) {\n      return false\n    }\n\n    const attached = attachNodeToTree(cacheTree, newPath, removedNode)\n    if (!attached) {\n      return false\n    }\n\n    get().setFileTree(cacheTree)\n    return true\n  },\n  syncOpenTabsForPathChange: async (oldPath: string, newPath: string) => {\n    const currentTabs = get().openTabs\n    const currentActiveTabId = get().activeTabId\n    const newTabs = currentTabs.map(tab => {\n      if (tab.path !== oldPath) {\n        return tab\n      }\n\n      return {\n        ...tab,\n        path: newPath,\n        name: newPath.split('/').pop() || newPath,\n      }\n    })\n\n    const nextActiveTabId = currentTabs.some(tab => tab.path === oldPath)\n      ? currentActiveTabId\n      : get().activeTabId\n\n    set({ openTabs: newTabs, activeTabId: nextActiveTabId })\n    const store = await getStore()\n    await store.set('openTabs', newTabs)\n    await store.set('activeTabId', nextActiveTabId)\n  },\n  fileTreeLoading: false,\n  updateFileStats: async (basePath: string, tree: DirTree[]) => {\n    const workspace = await getWorkspacePath()\n    \n    for (const entry of tree) {\n      // 跳过非本地文件（远程同步文件）\n      if (entry.isFile && entry.isLocale) {\n        const filePath = await join(basePath, entry.name)\n        try {\n          let fileStat\n          if (workspace.isCustom) {\n            // 自定义工作区，使用绝对路径\n            fileStat = await stat(filePath)\n          } else {\n            // 默认工作区，使用AppData路径\n            const relPath = await toWorkspaceRelativePath(filePath)\n            const pathOptions = await getFilePathOptions(relPath)\n            fileStat = await stat(pathOptions.path, { baseDir: pathOptions.baseDir })\n          }\n          entry.createdAt = fileStat.birthtime?.toISOString()\n          entry.modifiedAt = fileStat.mtime?.toISOString()\n          entry.size = fileStat.size\n        } catch {\n          // 静默失败，不阻塞排序功能\n        }\n      } else if (entry.isDirectory && entry.children) {\n        const dirPath = await join(basePath, entry.name)\n        await get().updateFileStats(dirPath, entry.children)\n      }\n    }\n    return tree\n  },\n  \n  // 按需加载文件统计信息（仅在需要排序时）\n  loadFileStatsIfNeeded: async () => {\n    const fileTree = get().fileTree\n    \n    // 检查是否已加载过统计信息（检查第一个文件）\n    const hasStats = fileTree.some(entry => \n      entry.isFile && (entry.createdAt !== undefined || entry.modifiedAt !== undefined)\n    )\n    \n    if (hasStats) {\n      // 已经加载过，无需重复加载\n      return\n    }\n    \n    // 加载统计信息\n    const workspace = await getWorkspacePath()\n    // 使用正确的基础路径\n    const basePath = workspace.isCustom ? workspace.path : await join(await appDataDir(), 'article')\n    await get().updateFileStats(basePath, fileTree)\n    set({ fileTree: [...fileTree] }) // 触发重新渲染\n  },\n  \n  loadFileTree: async () => {\n    set({ fileTreeLoading: true })\n    set({ fileTree: [] })\n\n    // 确保 collapsibleList 已初始化\n    if (!get().collapsibleListInitialized) {\n      await get().initCollapsibleList()\n    }\n\n    // 获取当前工作区路径\n    const workspace = await getWorkspacePath()\n    \n    // 确保工作区目录存在\n    if (workspace.isCustom) {\n      // 自定义工作区\n      const isWorkspaceExists = await exists(workspace.path)\n      if (!isWorkspaceExists) {\n        await mkdir(workspace.path)\n      }\n    } else {\n      // 默认工作区\n      const isArticleDir = await exists('article', { baseDir: BaseDirectory.AppData })\n      if (!isArticleDir) {\n        await mkdir('article', { baseDir: BaseDirectory.AppData })\n      }\n    }\n\n    // 读取工作区文件（仅根目录）\n    let dirs: DirTree[] = []\n    if (workspace.isCustom) {\n      // 自定义工作区\n      dirs = (await readDir(workspace.path))\n        .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.')).map(file => ({\n          ...file,\n          isEditing: false,\n          isLocale: true,\n          parent: undefined,\n          sha: '',\n          createdAt: undefined,\n          modifiedAt: undefined,\n          children: file.isDirectory ? [] : undefined\n        }))\n    } else {\n      // 默认工作区\n      dirs = (await readDir('article', { baseDir: BaseDirectory.AppData }))\n        .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.')).map(file => ({\n          ...file,\n          isEditing: false,\n          isLocale: true,\n          parent: undefined,\n          sha: '',\n          createdAt: undefined,\n          modifiedAt: undefined,\n          children: file.isDirectory ? [] : undefined\n        }))\n    }\n    \n    // 为已展开的文件夹加载子内容\n    const collapsibleList = get().collapsibleList\n    if (collapsibleList.length > 0) {\n      // 只加载根级别已展开的文件夹\n      const rootExpandedFolders = dirs.filter(dir => dir.isDirectory && collapsibleList.includes(dir.name))\n      for (const folder of rootExpandedFolders) {\n        await loadFolderChildren(workspace, folder)\n      }\n    }\n    \n    // 递归加载已展开文件夹的子内容\n    async function loadFolderChildren(workspace: any, folder: DirTree, parentPath: string = '') {\n      const folderPath = parentPath ? `${parentPath}/${folder.name}` : folder.name\n      const fullPath = await join(workspace.path, folderPath)\n      \n      let children: DirTree[] = []\n      \n      // 检查目录是否存在\n      let dirExists = false\n      try {\n        if (workspace.isCustom) {\n          dirExists = await exists(fullPath)\n        } else {\n          const dirRelative = await toWorkspaceRelativePath(fullPath)\n          const pathOptions = await getFilePathOptions(dirRelative)\n          dirExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n      } catch {\n        dirExists = false\n      }\n      \n      // 如果目录存在，加载本地文件\n      if (dirExists) {\n        try {\n          if (workspace.isCustom) {\n            children = (await readDir(fullPath))\n              .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.')).map(file => ({\n                ...file,\n                parent: folder,\n                isEditing: false,\n                isLocale: true,\n                sha: '',\n                createdAt: undefined,\n                modifiedAt: undefined,\n                children: file.isDirectory ? [] : undefined\n              })) as DirTree[]\n          } else {\n            const dirRelative = await toWorkspaceRelativePath(fullPath)\n            const pathOptions = await getFilePathOptions(dirRelative)\n            children = (await readDir(pathOptions.path, { baseDir: pathOptions.baseDir }))\n              .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.')).map(file => ({\n                ...file,\n                parent: folder,\n                isEditing: false,\n                isLocale: true,\n                sha: '',\n                createdAt: undefined,\n                modifiedAt: undefined,\n                children: file.isDirectory ? [] : undefined\n              })) as DirTree[]\n          }\n        } catch {\n          // 读取失败，使用空数组\n        }\n      }\n      \n      folder.children = children\n      \n      // 递归加载子文件夹中已展开的文件夹\n      for (const child of children) {\n        if (child.isDirectory && collapsibleList.includes(`${folderPath}/${child.name}`)) {\n          await loadFolderChildren(workspace, child, folderPath)\n        }\n      }\n    }\n        \n    // 排序文件树\n    const sortedDirs = get().sortFileTree(dirs)\n    set({ fileTree: sortedDirs })\n\n    // 先显示本地文件树\n    set({ fileTreeLoading: false })\n\n    // 初始化向量索引状态（异步，不阻塞界面）\n    get().initVectorIndexedFiles()\n\n    // 异步加载远程同步文件（不阻塞界面）\n    get().loadRemoteSyncFiles()\n  },\n  \n  // 加载远程同步文件（后台任务）\n  loadRemoteSyncFiles: async () => {\n    try {\n      const store = await getStore();\n      const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github'\n      \n      if (primaryBackupMethod === 'github') {\n        const accessToken = await store.get<string>('accessToken')\n        if (!accessToken) {\n          return\n        }\n      } else if (primaryBackupMethod === 'gitee') {\n        const giteeAccessToken = await store.get<string>('giteeAccessToken')\n        if (!giteeAccessToken) {\n          return\n        }\n      } else if (primaryBackupMethod === 'gitlab') {\n        const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n        if (!gitlabAccessToken) {\n          return\n        }\n      } else if (primaryBackupMethod === 'gitea') {\n        const giteaAccessToken = await store.get<string>('giteaAccessToken')\n        if (!giteaAccessToken) {\n          return\n        }\n      } else if (primaryBackupMethod === 's3') {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (!s3Config || !s3Config.accessKeyId || !s3Config.secretAccessKey || !s3Config.region || !s3Config.bucket) {\n          return\n        }\n      } else if (primaryBackupMethod === 'webdav') {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (!webdavConfig || !webdavConfig.url || !webdavConfig.username || !webdavConfig.password) {\n          return\n        }\n      }\n\n    // 只为根目录和本地存在的已展开文件夹加载远程文件\n    // 云端文件夹默认折叠，不加载其子内容\n    const workspace = await getWorkspacePath()\n    const collapsibleList = get().collapsibleList\n    const pathsToLoad: string[] = [''] // 总是加载根目录\n    \n    // 检查 collapsibleList 中的路径是否在本地存在，或者尝试加载远程文件夹\n    for (const path of collapsibleList) {\n      const fullPath = await join(workspace.path, path)\n      let dirExists = false\n\n      try {\n        if (workspace.isCustom) {\n          dirExists = await exists(fullPath)\n        } else {\n          const dirRelative = await toWorkspaceRelativePath(fullPath)\n          const pathOptions = await getFilePathOptions(dirRelative)\n          dirExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n      } catch {\n        dirExists = false\n      }\n\n      // 本地存在的文件夹，或者对于云同步（GitHub/Gitee/GitLab/Gitea/S3/WebDAV），即使本地不存在也尝试加载远程\n      // 这样可以显示仅存在于云端的文件夹\n      if (dirExists || primaryBackupMethod !== 'github') {\n        // 对于非 Git 平台，总是尝试加载\n        pathsToLoad.push(path)\n      } else if (dirExists) {\n        // 对于 Git 平台，只加载本地存在的\n        pathsToLoad.push(path)\n      }\n    }\n    \n    // 使用 Promise.all 并发请求所有路径的远程文件\n    const loadPromises = pathsToLoad.map(async path => {\n      try {\n        let files;\n        switch (primaryBackupMethod) {\n          case 'github':\n            const githubRepo = await getSyncRepoName('github');\n            files = await getGithubFiles({ path, repo: githubRepo });\n            break;\n          case 'gitee':\n            const giteeRepo = await getSyncRepoName('gitee');\n            files = await getGiteeFiles({ path, repo: giteeRepo });\n            break;\n          case 'gitlab':\n            const gitlabRepo = await getSyncRepoName('gitlab');\n            files = await getGitlabFiles({ path, repo: gitlabRepo });\n            break;\n          case 'gitea':\n            const giteaRepo = await getSyncRepoName('gitea');\n            files = await getGiteaFiles({ path, repo: giteaRepo });\n            break;\n          case 's3': {\n            const s3Config = await store.get<S3Config>('s3SyncConfig')\n            if (s3Config) {\n              files = await s3ListObjects(s3Config, path)\n            }\n            break;\n          }\n          case 'webdav': {\n            const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n            if (webdavConfig) {\n              files = await webdavListObjects(webdavConfig, path)\n            }\n            break;\n          }\n        }\n\n        if (files) {\n          const dirs = get().fileTree\n\n          // S3 或 WebDAV 文件处理\n          if (primaryBackupMethod === 's3' || primaryBackupMethod === 'webdav') {\n            const s3Files = files as Array<{ key: string; etag: string; lastModified: string; size: number }>\n            let prefix = ''\n            if (primaryBackupMethod === 's3') {\n              const config = await store.get<S3Config>('s3SyncConfig')\n              prefix = config?.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n            } else {\n              const config = await store.get<WebDAVConfig>('webdavSyncConfig')\n              prefix = config?.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n            }\n            const fullPrefix = prefix ? `${prefix}/${path}` : path\n\n            s3Files.forEach((file) => {\n              const fileName = file.key.split('/').pop() || file.key\n              if (fileName.startsWith('.')) {\n                return;\n              }\n\n              // 计算相对路径\n              const relativePath = fullPrefix ? file.key.substring(fullPrefix.length + 1) : file.key\n              const isDirectChild = !relativePath.includes('/')\n\n              if (!isDirectChild) {\n                return\n              }\n\n              const isDirectory = file.key.endsWith('/')\n\n              // 移除 pathPrefix 前缀，转换为本地相对路径\n              let localItemPath = file.key\n              if (prefix && localItemPath.startsWith(prefix + '/')) {\n                localItemPath = localItemPath.substring(prefix.length + 1)\n              }\n\n              let currentFolder: DirTree | undefined\n              if (isDirectory) {\n                currentFolder = getCurrentFolder(localItemPath, dirs)?.parent\n              } else {\n                const filePath = localItemPath.split('/').slice(0, -1).join('/')\n                currentFolder = getCurrentFolder(filePath, dirs)\n              }\n\n              if (localItemPath.includes('/')) {\n                const index = currentFolder?.children?.findIndex(item => item.name === fileName)\n                if (index !== -1 && index !== undefined && currentFolder?.children) {\n                  currentFolder.children[index].sha = file.etag\n                  currentFolder.children[index].size = file.size\n                  currentFolder.children[index].modifiedAt = file.lastModified\n                } else {\n                  currentFolder?.children?.push({\n                    name: fileName,\n                    isFile: !isDirectory,\n                    isSymlink: false,\n                    parent: currentFolder,\n                    isEditing: false,\n                    isDirectory: isDirectory,\n                    sha: file.etag,\n                    size: file.size,\n                    isLocale: false,\n                    modifiedAt: file.lastModified,\n                    children: isDirectory ? [] : undefined\n                  })\n                }\n              } else {\n                const index = dirs.findIndex(item => item.name === fileName)\n                if (index !== -1 && index !== undefined) {\n                  dirs[index].sha = file.etag\n                  dirs[index].size = file.size\n                  dirs[index].modifiedAt = file.lastModified\n                } else {\n                  (dirs as any).push({\n                    name: fileName,\n                    isFile: !isDirectory,\n                    isSymlink: false,\n                    parent: undefined,\n                    isEditing: false,\n                    isDirectory: isDirectory,\n                    sha: file.etag,\n                    size: file.size,\n                    isLocale: false,\n                    modifiedAt: file.lastModified,\n                    children: isDirectory ? [] : undefined\n                  })\n                }\n              }\n            })\n          } else {\n            // Git 平台处理逻辑\n            files.forEach((file: GithubContent | GiteeFile | GiteaDirectoryItem) => {\n              // 过滤以\".\"开头的文件和文件夹\n              if (file.name.startsWith('.')) {\n                return;\n              }\n\n              // 只加载直接子项，不加载孙子项\n              const relativePath = path ? file.path.substring(path.length + 1) : file.path\n              const isDirectChild = !relativePath.includes('/')\n\n              if (!isDirectChild) {\n                return // 跳过非直接子项\n              }\n\n              const itemPath = file.path;\n              let currentFolder: DirTree | undefined\n              if (file.type === 'dir') {\n                currentFolder = getCurrentFolder(itemPath, dirs)?.parent\n              } else {\n                const filePath = itemPath.split('/').slice(0, -1).join('/')\n                currentFolder = getCurrentFolder(filePath, dirs)\n              }\n              if (itemPath.includes('/')) {\n                const index = currentFolder?.children?.findIndex(item => item.name === file.name)\n                if (index !== -1 && index !== undefined && currentFolder?.children) {\n                  currentFolder.children[index].sha = file.sha\n                  currentFolder.children[index].size = (file as any).size\n                } else {\n                  currentFolder?.children?.push({\n                    name: file.name,\n                    isFile: file.type === 'file',\n                    isSymlink: false,\n                    parent: currentFolder,\n                    isEditing: false,\n                    isDirectory: file.type === 'dir',\n                    sha: file.sha,\n                    size: (file as any).size,\n                    isLocale: false,\n                    children: file.type === 'dir' ? [] : undefined\n                  })\n                }\n              } else {\n                const index = dirs.findIndex(item => item.name === file.name)\n                if (index !== -1 && index !== undefined) {\n                  dirs[index].sha = file.sha\n                  dirs[index].size = (file as any).size\n                } else {\n                  (dirs as any).push({\n                    name: file.name,\n                    isFile: file.type === 'file',\n                    isSymlink: false,\n                    parent: undefined,\n                    isEditing: false,\n                    isDirectory: file.type === 'dir',\n                    sha: file.sha,\n                    size: (file as any).size,\n                    isLocale: false,\n                    children: file.type === 'dir' ? [] : undefined\n                  })\n                }\n              }\n            });\n          }\n          set({ fileTree: dirs })\n        }\n      } catch {\n      }\n    });\n\n    // 等待所有远程文件加载完成\n    await Promise.all(loadPromises)\n  } catch {\n  }\n},\n  // 加载文件夹内部的本地和远程文件（按需加载）\n  loadCollapsibleFiles: async (fullpath: string) => {\n    const cacheTree: DirTree[] = get().fileTree\n    const currentFolder = getCurrentFolder(fullpath, cacheTree)\n\n    if (!currentFolder) {\n      return\n    }\n\n    // 检查是否是目录（防止误将文件当作目录处理）\n    if (!currentFolder.isDirectory) {\n      return\n    }\n\n    // 如果已经加载过子内容，则跳过\n    if (currentFolder.children && currentFolder.children.length > 0) {\n      // 仅异步更新远程同步状态\n      get().loadFolderRemoteFiles(fullpath)\n      return\n    }\n    \n    // 检查是否配置了云同步\n    const store = await getStore();\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let hasCloudSync = false\n    \n    if (primaryBackupMethod === 'github') {\n      const accessToken = await store.get<string>('accessToken')\n      hasCloudSync = !!accessToken\n    } else if (primaryBackupMethod === 'gitee') {\n      const giteeAccessToken = await store.get<string>('giteeAccessToken')\n      hasCloudSync = !!giteeAccessToken\n    } else if (primaryBackupMethod === 'gitlab') {\n      const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n      hasCloudSync = !!gitlabAccessToken\n    } else if (primaryBackupMethod === 'gitea') {\n      const giteaAccessToken = await store.get<string>('giteaAccessToken')\n      hasCloudSync = !!giteaAccessToken\n    } else if (primaryBackupMethod === 's3') {\n      const s3Config = await store.get<S3Config>('s3SyncConfig')\n      hasCloudSync = !!(s3Config && s3Config.accessKeyId && s3Config.secretAccessKey && s3Config.region && s3Config.bucket)\n    } else if (primaryBackupMethod === 'webdav') {\n      const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n      hasCloudSync = !!(webdavConfig && webdavConfig.url && webdavConfig.username && webdavConfig.password)\n    }\n\n    // 只有在配置了云同步时才设置加载状态\n    if (hasCloudSync) {\n      currentFolder.loading = true\n      set({ fileTree: [...cacheTree] })\n    }\n    \n    // 尝试加载本地子目录内容\n    const workspace = await getWorkspacePath()\n    const fullFolderPath = await join(workspace.path, fullpath)\n    \n    let children: DirTree[] = []\n    \n    // 检查目录是否存在\n    let dirExists = false\n    try {\n      if (workspace.isCustom) {\n        dirExists = await exists(fullFolderPath)\n      } else {\n        const dirRelative = await toWorkspaceRelativePath(fullFolderPath)\n        const pathOptions = await getFilePathOptions(dirRelative)\n        dirExists = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n    } catch {\n      dirExists = false\n    }\n    \n    // 如果目录存在，加载本地文件\n    if (dirExists) {\n      try {\n        if (workspace.isCustom) {\n          children = (await readDir(fullFolderPath))\n            .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.') && (file.isDirectory || file.name.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template|jpg|jpeg|png|gif|bmp|webp|svg)$/i)))\n            .map(file => ({\n              ...file,\n              parent: currentFolder,\n              isEditing: false,\n              isLocale: true,\n              sha: '',\n              createdAt: undefined,\n              modifiedAt: undefined,\n              children: file.isDirectory ? [] : undefined\n            })) as DirTree[]\n        } else {\n          const dirRelative = await toWorkspaceRelativePath(fullFolderPath)\n          const pathOptions = await getFilePathOptions(dirRelative)\n          children = (await readDir(pathOptions.path, { baseDir: pathOptions.baseDir }))\n            .filter(file => file.name !== '.DS_Store' && !file.name.startsWith('.') && (file.isDirectory || file.name.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template|jpg|jpeg|png|gif|bmp|webp|svg)$/i)))\n            .map(file => ({\n              ...file,\n              parent: currentFolder,\n              isEditing: false,\n              isLocale: true,\n              sha: '',\n              createdAt: undefined,\n              modifiedAt: undefined,\n              children: file.isDirectory ? [] : undefined\n            })) as DirTree[]\n        }\n      } catch {\n        // 读取失败，使用空数组\n      }\n    }\n\n    // 设置子节点（可能为空）\n    currentFolder.children = children\n    set({ fileTree: cacheTree })\n    \n    // 异步加载远程同步文件状态（不阻塞界面）\n    // 这将会填充仅存在于云端的文件\n    get().loadFolderRemoteFiles(fullpath)\n  },\n  \n  // 加载特定文件夹的远程同步文件（后台任务）\n  loadFolderRemoteFiles: async (fullpath: string) => {\n    const store = await getStore();\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    \n    // 检查是否配置了访问令牌\n    if (primaryBackupMethod === 'github') {\n      const accessToken = await store.get<string>('accessToken')\n      if (!accessToken) return\n    } else if (primaryBackupMethod === 'gitee') {\n      const giteeAccessToken = await store.get<string>('giteeAccessToken')\n      if (!giteeAccessToken) return\n    } else if (primaryBackupMethod === 'gitlab') {\n      const gitlabAccessToken = await store.get<string>('gitlabAccessToken')\n      if (!gitlabAccessToken) return\n    } else if (primaryBackupMethod === 'gitea') {\n      const giteaAccessToken = await store.get<string>('giteaAccessToken')\n      if (!giteaAccessToken) return\n    } else if (primaryBackupMethod === 's3') {\n      const s3Config = await store.get<S3Config>('s3SyncConfig')\n      if (!s3Config || !s3Config.accessKeyId || !s3Config.secretAccessKey || !s3Config.region || !s3Config.bucket) return\n    } else if (primaryBackupMethod === 'webdav') {\n      const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n      if (!webdavConfig || !webdavConfig.url || !webdavConfig.username || !webdavConfig.password) return\n    }\n\n    try {\n      let files;\n      switch (primaryBackupMethod) {\n        case 'github':\n          const githubRepo1 = await getSyncRepoName('github');\n          files = await getGithubFiles({ path: fullpath, repo: githubRepo1 });\n          break;\n        case 'gitee':\n          const giteeRepo1 = await getSyncRepoName('gitee');\n          files = await getGiteeFiles({ path: fullpath, repo: giteeRepo1 });\n          break;\n        case 'gitlab':\n          const gitlabRepo1 = await getSyncRepoName('gitlab');\n          files = await getGitlabFiles({ path: fullpath, repo: gitlabRepo1 });\n          break;\n        case 'gitea':\n          const giteaRepo1 = await getSyncRepoName('gitea');\n          files = await getGiteaFiles({ path: fullpath, repo: giteaRepo1 });\n          break;\n        case 's3': {\n          const s3Config = await store.get<S3Config>('s3SyncConfig')\n          if (s3Config) {\n            files = await s3ListObjects(s3Config, fullpath)\n          }\n          break;\n        }\n        case 'webdav': {\n          const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n          if (webdavConfig) {\n            files = await webdavListObjects(webdavConfig, fullpath)\n          }\n          break;\n        }\n      }\n\n      if (files) {\n        const cacheTree = get().fileTree\n        const currentFolder = getCurrentFolder(fullpath, cacheTree)\n\n        if (currentFolder) {\n          // S3 和 WebDAV 返回的文件格式相同，需要特殊处理\n          if (primaryBackupMethod === 's3' || primaryBackupMethod === 'webdav') {\n            const s3Files = files as Array<{ key: string; etag: string; lastModified: string; size: number }>\n            let prefix = ''\n            if (primaryBackupMethod === 's3') {\n              const config = await store.get<S3Config>('s3SyncConfig')\n              prefix = config?.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n            } else {\n              const config = await store.get<WebDAVConfig>('webdavSyncConfig')\n              prefix = config?.pathPrefix ? config.pathPrefix.trim().replace(/\\/+$/, '') : ''\n            }\n            const fullPrefix = prefix ? `${prefix}/${fullpath}` : fullpath\n\n            s3Files.forEach((file) => {\n              // 提取文件名（key 的最后一部分）\n              const fileName = file.key.split('/').pop() || file.key\n              // 过滤以\".\"开头的文件和文件夹\n              if (fileName.startsWith('.')) {\n                return;\n              }\n\n              // 只加载直接子项，不加载孙子项\n              // 例如: fullPrefix='test', file.key='test/file.md' → 加载\n              //      fullPrefix='test', file.key='test/sub/file.md' → 跳过\n              const relativePath = fullPrefix ? file.key.substring(fullPrefix.length + 1) : file.key\n              const isDirectChild = !relativePath.includes('/')\n\n              if (!isDirectChild) {\n                return // 跳过非直接子项\n              }\n\n              // S3 没有文件夹概念，检查 key 是否以 / 结尾来判断是否是\"文件夹\"\n              const isDirectory = file.key.endsWith('/')\n\n              const index = currentFolder.children?.findIndex(item => item.name === fileName)\n              if (index !== undefined && index !== -1 && currentFolder.children) {\n                currentFolder.children[index].sha = file.etag\n                currentFolder.children[index].size = file.size\n                currentFolder.children[index].modifiedAt = file.lastModified\n              } else {\n                currentFolder.children?.push({\n                  name: fileName,\n                  isFile: !isDirectory,\n                  isSymlink: false,\n                  parent: currentFolder,\n                  isEditing: false,\n                  isDirectory: isDirectory,\n                  sha: file.etag,\n                  size: file.size,\n                  isLocale: false,\n                  modifiedAt: file.lastModified,\n                  children: isDirectory ? [] : undefined\n                })\n              }\n            })\n          } else {\n            // Git 平台处理逻辑\n            files.forEach((file: GithubContent | GiteeFile | GiteaDirectoryItem) => {\n              // 过滤以\".\"开头的文件和文件夹\n              if (file.name.startsWith('.')) {\n                return;\n              }\n\n              // 只加载直接子项，不加载孙子项\n              // 例如: fullpath='test', file.path='test/file.md' → 加载\n              //      fullpath='test', file.path='test/sub/file.md' → 跳过\n              const relativePath = fullpath ? file.path.substring(fullpath.length + 1) : file.path\n              const isDirectChild = !relativePath.includes('/')\n\n              if (!isDirectChild) {\n                return // 跳过非直接子项\n              }\n\n              const index = currentFolder.children?.findIndex(item => item.name === file.name)\n              if (index !== undefined && index !== -1 && currentFolder.children) {\n                currentFolder.children[index].sha = file.sha\n                currentFolder.children[index].size = (file as any).size\n              } else {\n                currentFolder.children?.push({\n                  name: file.name,\n                  isFile: file.type === 'file',\n                  isSymlink: false,\n                  parent: currentFolder,\n                  isEditing: false,\n                  isDirectory: file.type === 'dir',\n                  sha: file.sha,\n                  size: (file as any).size,\n                  isLocale: false,\n                  children: file.type === 'file' ? undefined : []\n                })\n              }\n            });\n          }\n\n          // 移除加载状态\n          currentFolder.loading = false\n          set({ fileTree: cacheTree })\n        }\n      }\n    } catch {\n      // 确保加载状态被移除\n      const cacheTree = get().fileTree\n      const currentFolder = getCurrentFolder(fullpath, cacheTree)\n      if (currentFolder) {\n        currentFolder.loading = false\n        set({ fileTree: [...cacheTree] })\n      }\n    }\n  },\n  newFolder: async () => {\n    const cacheTree = cloneDeep(get().fileTree)\n    const exists = cacheTree.find(item => item.name === '' && item.isDirectory)\n    if (exists) {\n      return\n    }\n    const node = {\n      name: '',\n      isFile: false,\n      isDirectory: true,\n      isSymlink: false,\n      isEditing: true,\n      isLocale: true,\n      children: []\n    }\n\n    try {\n      cacheTree.unshift(node as DirTree)\n      set({ fileTree: cacheTree })\n    } catch {\n    }\n  },\n  newFile: async () => {\n    // 检查现有树中是否已有空文件名的文件（正在编辑中）\n    const cacheTree = cloneDeep(get().fileTree)\n    const exists = cacheTree.find(item => item.name === '' && item.isFile)\n    if (exists) {\n      return\n    }\n  \n    // 判断 activeFilePath 是否存在 parent\n    const path = get().activeFilePath;\n    if (path.includes('/')) {\n      // 在当前活动文件的父文件夹下创建新文件\n      const folderPath = path.split('/').slice(0, -1).join('/')\n      const currentFolder = getCurrentFolder(folderPath, cacheTree)\n      \n      // 如果文件夹中已经有一个空名称的文件，不再创建新的\n      if (currentFolder?.children?.find(item => item.name === '' && item.isFile)) {\n        return\n      }\n      \n      // 确保文件夹是展开状态\n      const collapsibleList = get().collapsibleList\n      if (!collapsibleList.includes(folderPath)) {\n        collapsibleList.push(folderPath)\n        set({ collapsibleList })\n      }\n      \n      if (currentFolder) {\n        const newFile: DirTree = {\n          name: '',\n          isFile: true,\n          isSymlink: false,\n          parent: currentFolder,\n          isEditing: true,\n          isDirectory: false,\n          isLocale: true,\n          sha: '',\n          children: []\n        }\n        currentFolder.children?.unshift(newFile)\n        set({ fileTree: cacheTree })\n      }\n    } else {\n      // 不存在 parent，直接在根目录下创建\n      const newFile: DirTree = {\n        name: '',\n        isFile: true,\n        isSymlink: false,\n        parent: undefined,\n        isEditing: true,\n        isDirectory: false,\n        isLocale: true,\n        sha: '',\n        children: []\n      }\n      cacheTree.unshift(newFile)\n      set({ fileTree: cacheTree })\n    }\n  },\n\n  newFileOnFolder: async (path: string) => {\n    // 获取 parent folder\n    const cacheTree = cloneDeep(get().fileTree)\n    const currentFolder = path.includes('/') ? getCurrentFolder(path, cacheTree) : cacheTree.find(item => item.name === path)\n    \n    // 获取工作区路径信息\n    const workspace = await getWorkspacePath()\n    \n    // 创建新文件\n    const file = `新建文件-${new Date().getTime()}.md`\n    const fullPath = `${path}/${file}`\n    const pathOptions = await getFilePathOptions(fullPath)\n    \n    // 写入空文件\n    if (workspace.isCustom) {\n      await writeTextFile(pathOptions.path, '')\n    } else {\n      await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n    }\n\n    // 更新树\n    const node = {\n      name: file,\n      isFile: true,\n      isDirectory: false,\n      isSymlink: false,\n      isEditing: false,\n      isLocale: true,\n      parent: currentFolder,\n      sha: '',\n      children: []\n    }\n\n    try {\n      currentFolder?.children?.unshift(node as DirTree)\n      set({ fileTree: cacheTree })\n      get().setActiveFilePath(fullPath)\n    } catch {\n    }\n  },\n  newFolderInFolder: async (path: string) => {\n    // 获取 parent folder\n    const cacheTree = cloneDeep(get().fileTree)\n    const currentFolder = path.includes('/') ? getCurrentFolder(path, cacheTree) : cacheTree.find(item => item.name === path)\n    \n    // 如果文件夹中已存在未命名文件夹，不创建新的\n    const hasEmptyFolder = currentFolder?.children?.find(item => item.name === '' && item.isDirectory)\n    if (hasEmptyFolder) {\n      return\n    }\n\n    // 更新树\n    const node = {\n      name: '',\n      isFile: false,\n      isDirectory: true,\n      isSymlink: false,\n      isEditing: true,\n      isLocale: true,\n      parent: currentFolder,\n      sha: '',\n      children: []\n    }\n\n    try {\n      currentFolder?.children?.unshift(node as DirTree)\n      set({ fileTree: cacheTree })\n    } catch {\n    }\n  },\n\n  collapsibleList: [],\n  collapsibleListInitialized: false,\n  initCollapsibleList: async () => {\n    // 防止重复初始化\n    if (get().collapsibleListInitialized) {\n      return\n    }\n\n    const store = await getStore();\n    const res = await store.get<string[]>('collapsibleList')\n    const activeFilePath = await store.get<string>('activeFilePath')\n    set({\n      collapsibleList: res ? uniq(res.filter(item => !item.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template|jpg|jpeg|png|gif|bmp|webp|svg)$/i))) : [],\n      collapsibleListInitialized: true\n    })\n\n    if (activeFilePath) {\n      set({ activeFilePath })\n\n      // 检查是否是文件夹（所有支持的文件扩展名都是文件，不是文件夹）\n      if (!activeFilePath.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template|jpg|jpeg|png|gif|bmp|webp|svg)$/i)) {\n        // 文件夹：确保展开并加载内容\n        if (!get().collapsibleList.includes(activeFilePath)) {\n          await get().setCollapsibleList(activeFilePath, true)\n        }\n        await get().loadCollapsibleFiles(activeFilePath)\n      } else {\n        // 文件：读取内容\n        get().readArticle(activeFilePath)\n      }\n    }\n  },\n  \n  setCollapsibleList: async (path: string, value: boolean) => {\n    const collapsibleList = cloneDeep(get().collapsibleList)\n    if (value) {\n      collapsibleList.push(path)\n    } else {\n      const index = collapsibleList.indexOf(path)\n      if (index !== -1) {\n        collapsibleList.splice(index, 1)\n      }\n    }\n    const store = await getStore();\n    await store.set('collapsibleList', collapsibleList)\n    set({ collapsibleList: uniq(collapsibleList).filter(item => !item.match(/\\.(md|txt|markdown|py|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|sh|bash|java|c|cpp|h|go|rs|sql|rb|php|vue|svelte|astro|toml|ini|conf|cfg|gitignore|env|example|template|jpg|jpeg|png|gif|bmp|webp|svg)$/i)) })\n  },\n  \n  expandAllFolders: async () => {\n    // Get all folder paths from fileTree recursively\n    const getAllFolderPaths = (tree: DirTree[], parentPath: string = ''): string[] => {\n      let paths: string[] = []\n      for (const item of tree) {\n        if (!item.isFile) {\n          const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name\n          paths.push(currentPath)\n          if (item.children && item.children.length > 0) {\n            paths = [...paths, ...getAllFolderPaths(item.children, currentPath)]\n          }\n        }\n      }\n      return paths\n    }\n    \n    const folderPaths = getAllFolderPaths(get().fileTree)\n    const store = await getStore()\n    await store.set('collapsibleList', folderPaths)\n    set({ collapsibleList: uniq(folderPaths) })\n    \n    // Load all children for expanded folders\n    for (const path of folderPaths) {\n      await get().loadCollapsibleFiles(path)\n    }\n  },\n  \n  collapseAllFolders: async () => {\n    const store = await getStore()\n    await store.set('collapsibleList', [])\n    set({ collapsibleList: [] })\n  },\n  \n  toggleAllFolders: async () => {\n    // If there are any expanded folders, collapse all; otherwise, expand all\n    if (get().collapsibleList.length > 0) {\n      await get().collapseAllFolders()\n    } else {\n      await get().expandAllFolders()\n    }\n  },\n  clearCollapsibleList: async () => {\n    set({ collapsibleList: [] })\n    const store = await getStore()\n    await store.set('collapsibleList', [])\n  },\n\n  currentArticle: '',\n  readFilePath: '',\n  isPulling: false, // 新增：拉取状态\n  justPulledFile: false, // 标记是否刚从远程拉取文件\n  skipSyncOnSave: false, // 标记是否跳过同步\n  aiGeneratingFilePath: null, // 标记当前正在 AI 生成的文件路径\n  aiTerminateFn: null, // AI 生成的终止函数\n\n  setReadFilePath: (path: string) => {\n    set({ readFilePath: path })\n  },\n\n  readArticle: async (path: string, sha?: string, autoSync = true) => {\n    get().setLoading(true)\n\n    // 设置当前正在读取的文件路径，用于避免竞态条件\n    set({ readFilePath: path })\n\n    // 处理文件名兼容性问题\n    let actualPath = path\n    if (hasInvalidFileNameChars(path)) {\n      actualPath = sanitizeFilePath(path)\n      // 更新活动文件路径为清理后的路径\n      await get().setActiveFilePath(actualPath)\n    }\n\n    // 优先加载本地内容（快速响应）\n    let localContent = ''\n\n    // 辅助函数：查找文件信息\n    const findFileInTree = (tree: DirTree[], targetPath: string): DirTree | null => {\n      for (const item of tree) {\n        const itemPath = computedParentPath(item)\n        if (itemPath === targetPath && item.isFile) {\n          return item\n        }\n        if (item.children && item.children.length > 0) {\n          const found = findFileInTree(item.children, targetPath)\n          if (found) return found\n        }\n      }\n      return null\n    }\n\n    try {\n      const workspace = await getWorkspacePath()\n      const pathOptions = await getFilePathOptions(actualPath)\n      if (workspace.isCustom) {\n        localContent = await readTextFile(pathOptions.path)\n      } else {\n        localContent = await readTextFile(pathOptions.path, { baseDir: pathOptions.baseDir })\n      }\n\n      // 检查是否是远程文件且本地内容为空\n      const fileTree = get().fileTree\n      const fileInfo = findFileInTree(fileTree, actualPath)\n      const isRemoteFile = fileInfo && !fileInfo.isLocale\n\n      // 如果是远程文件且本地内容为空，先显示编辑器（禁用），再异步拉取\n      if (isRemoteFile && (!localContent || localContent.trim() === '')) {\n        // 先设置当前内容为空，显示编辑器\n        set({ currentArticle: '', loading: true })\n\n        // 标记正在拉取\n        get().setIsPulling(true)\n        get().setJustPulledFile(true)\n\n        // 异步拉取远程内容\n        setTimeout(async () => {\n          try {\n            const remoteContent = await pullRemoteFile(actualPath)\n            await saveLocalFile(actualPath, remoteContent)\n\n            // 再次检查当前是否还是同一个文件\n            if (get().activeFilePath === actualPath) {\n              set({ currentArticle: remoteContent })\n              emitter.emit('editor-content-from-remote', { content: remoteContent })\n            }\n\n            // 拉取成功后，更新文件树的 isLocale 状态为本地文件\n            const cacheTree = cloneDeep(get().fileTree)\n            const fileNode = findFileInTree(cacheTree, actualPath)\n            if (fileNode) {\n              fileNode.isLocale = true\n              set({ fileTree: cacheTree })\n            }\n          } catch {\n            if (get().activeFilePath === actualPath) {\n              set({ currentArticle: '' })\n            }\n          } finally {\n            get().setIsPulling(false)\n            get().setLoading(false)\n            setTimeout(() => {\n              get().setJustPulledFile(false)\n            }, 1000)\n          }\n        }, 0)\n\n        return\n      }\n\n      // 正常的本地文件，显示内容（即使是空文件也正确显示）\n      set({ currentArticle: localContent })\n      // 本地内容加载完成，解除加载状态\n      get().setLoading(false)\n      // 检查文件的向量索引状态\n      get().checkFileVectorIndexed(actualPath)\n    } catch (error) {\n      // 本地文件不存在，检查是否是远程文件\n\n      // 先查找文件信息（可能 fileTree 还没加载完成）\n      const fileInfo = findFileInTree(get().fileTree, actualPath)\n\n      // 检查是否是\"文件不存在\"错误（兼容不同平台的大小写）\n      const errorMsg = error instanceof Error ? error.message : String(error)\n      const isFileNotFound = errorMsg.toLowerCase().includes('no such file') ||\n                            errorMsg.toLowerCase().includes('not found') ||\n                            errorMsg.toLowerCase().includes('系统找不到指定的路径')\n\n      if (isFileNotFound && fileInfo && !fileInfo.isLocale) {\n        // 先设置当前内容为空，显示编辑器\n        set({ currentArticle: '', loading: true })\n\n        // 标记正在拉取\n        get().setIsPulling(true)\n        get().setJustPulledFile(true)\n\n        // 异步拉取远程内容\n        setTimeout(async () => {\n          try {\n            const remoteContent = await pullRemoteFile(actualPath)\n            await saveLocalFile(actualPath, remoteContent)\n\n            // 再次检查当前是否还是同一个文件\n            if (get().activeFilePath === actualPath) {\n              set({ currentArticle: remoteContent })\n              emitter.emit('editor-content-from-remote', { content: remoteContent })\n            }\n\n            // 拉取成功后，更新文件树的 isLocale 状态为本地文件\n            const cacheTree = cloneDeep(get().fileTree)\n            const fileNode = findFileInTree(cacheTree, actualPath)\n            if (fileNode) {\n              fileNode.isLocale = true\n              set({ fileTree: cacheTree })\n            }\n          } catch {\n            if (get().activeFilePath === actualPath) {\n              set({ currentArticle: '' })\n            }\n          } finally {\n            get().setIsPulling(false)\n            get().setLoading(false)\n            setTimeout(() => {\n              get().setJustPulledFile(false)\n            }, 1000)\n          }\n        }, 0)\n      } else if (isFileNotFound) {\n        // 本地文件，创建空白文件\n        await ensureDirectoryExists(actualPath)\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(actualPath)\n\n        try {\n          if (workspace.isCustom) {\n            await writeTextFile(pathOptions.path, '')\n          } else {\n            await writeTextFile(pathOptions.path, '', { baseDir: pathOptions.baseDir })\n          }\n          set({ currentArticle: '' })\n          get().setLoading(false)\n        } catch {\n          get().setLoading(false)\n        }\n      } else {\n        set({ currentArticle: '' })\n        get().setLoading(false)\n      }\n    }\n\n    // 异步检查远程更新（使用新的 SyncManager）\n    // 只有当当前读取的文件路径仍然是 actualPath 时才执行同步\n    // 同时检查 activeFilePath 是否仍然匹配，防止竞态条件\n    if (autoSync && await hasNetworkConnection()) {\n      try {\n        // 在执行同步前检查路径是否仍然匹配\n        const currentReadPath = get().readFilePath\n        const currentActivePath = get().activeFilePath\n        if (currentReadPath === actualPath && currentActivePath === actualPath) {\n          const result = await syncOnOpen(actualPath)\n          // 在设置 content 前再次确认路径没有变化\n          if (result?.updated && result.content && get().activeFilePath === actualPath) {\n            // 拉取了新内容，更新 currentArticle\n            set({ currentArticle: result.content })\n          }\n        }\n      } catch {\n      }\n    }\n\n    // 读取完成后清除 readFilePath（仅当没有其他 readArticle 在执行时）\n    // 通过检查 activeFilePath 是否变化来判断\n    if (get().activeFilePath === actualPath) {\n      set({ readFilePath: '' })\n    }\n  },\n\n  // 向量计算相关状态\n  vectorCalcTimer: null as NodeJS.Timeout | null,\n  vectorCalcProgressInterval: null as NodeJS.Timeout | null,\n  vectorCalcProgress: 0, // 0-100，表示距离自动计算的进度\n  isVectorCalculating: false,\n  lastEditTime: 0,\n  pendingVectorContent: null as { path: string; content: string } | null,\n  // 向量索引状态\n  vectorIndexedFiles: new Map<string, number>(), // 文件名 -> 向量索引时间戳\n\n  setCurrentArticle: (content: string) => {\n    set({ currentArticle: content })\n  },\n\n  setIsPulling: (pulling: boolean) => {\n    set({ isPulling: pulling })\n  },\n\n  setJustPulledFile: (justPulled: boolean) => {\n    set({ justPulledFile: justPulled })\n  },\n\n  setSkipSyncOnSave: (skip: boolean) => {\n    set({ skipSyncOnSave: skip })\n  },\n\n  setAiGeneratingFilePath: (path: string | null) => {\n    set({ aiGeneratingFilePath: path })\n  },\n\n  setAiTerminateFn: (fn: (() => void) | null) => {\n    set({ aiTerminateFn: fn })\n  },\n\n  // 更新文件 sha 状态（推送成功后调用）\n  updateFileSha: (path: string, sha: string) => {\n    const cacheTree = cloneDeep(get().fileTree)\n\n    // 递归查找并更新文件的 sha\n    const updateShaInTree = (items: DirTree[], depth: number = 0): boolean => {\n      for (const item of items) {\n        const itemPath = computedParentPath(item)\n        if (itemPath === path && item.isFile) {\n          item.sha = sha\n          return true\n        }\n        if (item.children && updateShaInTree(item.children, depth + 1)) {\n          return true\n        }\n      }\n      return false\n    }\n\n    if (updateShaInTree(cacheTree)) {\n      const sortedTree = get().sortFileTree(cacheTree)\n      set({ fileTree: sortedTree })\n    } else {\n      // 未找到匹配的文件\n    }\n  },\n\n  saveCurrentArticle: async (content: string) => {\n    const path = get().activeFilePath\n    const justPulled = get().justPulledFile\n\n    if (path && content !== undefined && content !== null) {\n      // 如果是从远程刚拉取的文件，不触发推送（避免 SHA 不匹配错误）\n      if (justPulled) {\n        // 清除标志\n        get().setJustPulledFile(false)\n        // 只保存本地文件，不触发同步推送\n        const workspace = await getWorkspacePath()\n        const pathOptions = await getFilePathOptions(path)\n        if (workspace.isCustom) {\n          await writeTextFile(pathOptions.path, content)\n        } else {\n          await writeTextFile(pathOptions.path, content, { baseDir: pathOptions.baseDir })\n        }\n        set({ currentArticle: content })\n        return\n      }\n\n      // 清除之前的防抖定时器\n      const existingTimer = get().debounceSaveTimer\n      if (existingTimer) {\n        clearTimeout(existingTimer)\n      }\n\n      // 设置新的防抖定时器，500ms 后执行保存\n      // 这样可以合并短时间内多次 content change\n      // 保存 pendingContent 用于防抖检查\n      set({ pendingSaveContent: content, debounceSaveTimer: undefined })\n      const timer = setTimeout(async () => {\n        const state = get()\n        const debouncedContent = state.pendingSaveContent || content\n\n        // Bug fix: 检查路径是否仍然匹配，避免文件切换时保存到错误的文件\n        const currentActivePath = state.activeFilePath\n        if (currentActivePath !== path) {\n          // 文件已切换，取消保存\n          set({ debounceSaveTimer: null, pendingSaveContent: null })\n          return\n        }\n\n        set({ debounceSaveTimer: null, pendingSaveContent: null })\n\n        // 执行实际保存操作\n        const savePath = path\n        const saveContent = debouncedContent\n        const workspace = await getWorkspacePath()\n\n        // 检查文件是否存在\n        let isLocale = false\n        const pathOptions = await getFilePathOptions(savePath)\n        if (workspace.isCustom) {\n          isLocale = await exists(pathOptions.path)\n        } else {\n          isLocale = await exists(pathOptions.path, { baseDir: pathOptions.baseDir })\n        }\n\n        // 确保目录结构存在\n        if (savePath.includes('/')) {\n          let dir = ''\n          const dirPath = savePath.split('/')\n          for (let index = 0; index < dirPath.length - 1; index += 1) {\n            dir += `${dirPath[index]}/`\n            const dirOptions = await getFilePathOptions(dir)\n            let dirExists = false\n            if (workspace.isCustom) {\n              dirExists = await exists(dirOptions.path)\n            } else {\n              dirExists = await exists(dirOptions.path, { baseDir: dirOptions.baseDir })\n            }\n            if (!dirExists) {\n              if (workspace.isCustom) {\n                await mkdir(dirOptions.path)\n              } else {\n                await mkdir(dirOptions.path, { baseDir: dirOptions.baseDir })\n              }\n            }\n          }\n        }\n\n        // 保存文件内容\n        if (workspace.isCustom) {\n          await writeTextFile(pathOptions.path, saveContent)\n        } else {\n          await writeTextFile(pathOptions.path, saveContent, { baseDir: pathOptions.baseDir })\n        }\n\n        // 更新缓存树\n        if (!isLocale) {\n          const cacheTree = cloneDeep(get().fileTree)\n          const current = savePath.includes('/') ? getCurrentFolder(savePath, cacheTree) : cacheTree.find(item => item.name === savePath)\n          if (current) {\n            current.isLocale = true\n\n            // 更新父文件夹链的 isLocale 状态\n            const updateParentFolders = async (node: DirTree | undefined) => {\n              let parent = node\n              const pathParts = savePath.split('/')\n              let currentDepth = pathParts.length - 1\n\n              while (parent && currentDepth > 0) {\n                if (parent.isLocale) {\n                  break\n                }\n                const parentPath = pathParts.slice(0, currentDepth).join('/')\n                const parentOptions = await getFilePathOptions(parentPath)\n                let parentExists = false\n                try {\n                  if (workspace.isCustom) {\n                    parentExists = await exists(parentOptions.path)\n                  } else {\n                    parentExists = await exists(parentOptions.path, { baseDir: parentOptions.baseDir })\n                  }\n                } catch {\n                  parentExists = false\n                }\n                if (parentExists) {\n                  parent.isLocale = true\n                  parent = parent.parent\n                  currentDepth--\n                } else {\n                  break\n                }\n              }\n            }\n\n            await updateParentFolders(current.parent)\n          }\n          set({ fileTree: cacheTree })\n        }\n\n        // 触发防抖向量计算\n        if (savePath.endsWith('.md')) {\n          get().scheduleVectorCalculation(savePath, saveContent)\n        }\n\n        // 更新 currentArticle\n        set({ currentArticle: saveContent })\n\n        // 记录写作活动（独立事件日志，不受后续删除影响）\n        try {\n          const { recordWritingActivity } = await import('@/db/activity')\n          const fileName = savePath.split('/').pop() || savePath\n          await recordWritingActivity({\n            path: savePath,\n            title: fileName,\n            description: savePath,\n          })\n        } catch (error) {\n          console.error('记录写作活动失败:', error)\n        }\n\n        // 通知文件已保存，触发同步推送（除非设置了 skipSyncOnSave）\n        const shouldSkipSync = get().skipSyncOnSave\n        if (!shouldSkipSync) {\n          emitter.emit('article-saved', { path: savePath, content: saveContent })\n        }\n      }, 500)\n\n      // 保存待处理的内容（最新的内容）\n      set({ debounceSaveTimer: timer as any, pendingSaveContent: content })\n    }\n  },\n\n  // 安排向量计算（防抖5秒）\n  scheduleVectorCalculation: (path: string, content: string) => {\n    const state = get()\n    \n    // 清除之前的定时器\n    if (state.vectorCalcTimer) {\n      clearTimeout(state.vectorCalcTimer)\n    }\n    if (state.vectorCalcProgressInterval) {\n      clearInterval(state.vectorCalcProgressInterval)\n    }\n    \n    // 更新最后编辑时间和待处理内容\n    const now = Date.now()\n    set({ \n      lastEditTime: now,\n      pendingVectorContent: { path, content },\n      vectorCalcProgress: 0\n    })\n    \n    // 创建进度更新定时器（每100ms更新一次进度）\n    const progressInterval = setInterval(() => {\n      const elapsed = Date.now() - get().lastEditTime\n      const progress = Math.min((elapsed / 5000) * 100, 100)\n      set({ vectorCalcProgress: progress })\n      \n      if (progress >= 100) {\n        clearInterval(progressInterval)\n      }\n    }, 100)\n    \n    // 设置5秒后自动执行向量计算\n    const timer = setTimeout(() => {\n      clearInterval(progressInterval)\n      get().executeVectorCalculation()\n    }, 5000)\n    \n    set({ \n      vectorCalcTimer: timer as any,\n      vectorCalcProgressInterval: progressInterval as any\n    })\n  },\n\n  // 执行向量计算\n  executeVectorCalculation: async () => {\n    const state = get()\n    \n    // 如果没有待处理内容或正在计算中，直接返回\n    if (!state.pendingVectorContent || state.isVectorCalculating) {\n      return\n    }\n    \n    try {\n      set({ isVectorCalculating: true, vectorCalcProgress: 100 })\n      \n      const { path, content } = state.pendingVectorContent\n      const vectorStore = useVectorStore.getState()\n\n      // 执行向量计算\n      await vectorStore.processDocument(path, content)\n      // 更新向量索引状态\n      const vectorKey = getVectorDocumentKey(path)\n      const newMap = new Map(get().vectorIndexedFiles)\n      newMap.set(vectorKey, Date.now())\n      set({ vectorIndexedFiles: newMap })\n\n      // 清除待处理内容和定时器\n      if (state.vectorCalcTimer) {\n        clearTimeout(state.vectorCalcTimer)\n      }\n      if (state.vectorCalcProgressInterval) {\n        clearInterval(state.vectorCalcProgressInterval)\n      }\n      \n      set({ \n        pendingVectorContent: null,\n        vectorCalcTimer: null,\n        vectorCalcProgressInterval: null,\n        vectorCalcProgress: 0\n      })\n    } catch {\n      set({ isVectorCalculating: false })\n    }\n  },\n\n  // 取消向量计算\n  cancelVectorCalculation: () => {\n    const state = get()\n    if (state.vectorCalcTimer) {\n      clearTimeout(state.vectorCalcTimer)\n    }\n    if (state.vectorCalcProgressInterval) {\n      clearInterval(state.vectorCalcProgressInterval)\n    }\n    set({\n      vectorCalcTimer: null,\n      vectorCalcProgressInterval: null,\n      vectorCalcProgress: 0,\n      pendingVectorContent: null\n    })\n  },\n\n  // 检查文件是否已被向量索引\n  checkFileVectorIndexed: async (filePath: string) => {\n    const { checkVectorDocumentExists, getVectorDocumentsByFilename } = await import('@/db/vector')\n    const vectorKey = getVectorDocumentKey(filePath)\n    const hasVector = await checkVectorDocumentExists(vectorKey)\n    if (hasVector) {\n      // 获取向量文档记录更新时间\n      const docs = await getVectorDocumentsByFilename(vectorKey)\n      if (docs.length > 0) {\n        const latestTime = Math.max(...docs.map(d => d.updated_at))\n        const newMap = new Map(get().vectorIndexedFiles)\n        newMap.set(vectorKey, latestTime)\n        set({ vectorIndexedFiles: newMap })\n        return true\n      }\n    }\n    // 如果没有向量，从映射中移除\n    const newMap = new Map(get().vectorIndexedFiles)\n    newMap.delete(vectorKey)\n    set({ vectorIndexedFiles: newMap })\n    return false\n  },\n\n  // 清除文件的向量数据\n  clearFileVector: async (filePath: string) => {\n    const { deleteVectorDocumentsByFilename } = await import('@/db/vector')\n    const vectorKey = getVectorDocumentKey(filePath)\n    await deleteVectorDocumentsByFilename(vectorKey)\n    // 从映射中移除\n    const newMap = new Map(get().vectorIndexedFiles)\n    newMap.delete(vectorKey)\n    set({ vectorIndexedFiles: newMap })\n  },\n\n  // 初始化向量索引状态 - 加载所有已索引的文件\n  initVectorIndexedFiles: async () => {\n    try {\n      const { getAllVectorDocumentFilenames, getVectorDocumentsByFilename } = await import('@/db/vector')\n      const indexedFiles = await getAllVectorDocumentFilenames()\n\n      // 构建 vectorIndexedFiles Map\n      const vectorIndexedDocs = []\n      for (const file of indexedFiles) {\n        const docs = await getVectorDocumentsByFilename(file.filename)\n        vectorIndexedDocs.push(...docs)\n      }\n\n      const vectorIndexedMap = buildVectorIndexedMap(vectorIndexedDocs)\n\n      set({ vectorIndexedFiles: vectorIndexedMap })\n    } catch {\n    }\n  },\n\n  // 手动触发向量计算（使用当前文章内容）\n  triggerVectorCalculation: async () => {\n    const state = get()\n    if (!state.activeFilePath || state.isVectorCalculating) {\n      return\n    }\n\n    // 使用当前文章内容\n    const content = state.currentArticle\n    if (!content) {\n      return\n    }\n\n    // 设置待处理内容并执行\n    set({\n      pendingVectorContent: {\n        path: state.activeFilePath,\n        content\n      }\n    })\n\n    await get().executeVectorCalculation()\n  },\n\n  // 设置向量计算状态\n  setVectorCalcStatus: (path: string, status: 'idle' | 'calculating' | 'completed') => {\n    const fileTree = get().fileTree\n\n    // 递归查找并更新文件/文件夹的状态\n    const updateStatus = (items: DirTree[]): boolean => {\n      for (const item of items) {\n        const itemPath = computedParentPath(item)\n        if (itemPath === path) {\n          item.vectorCalcStatus = status\n          return true\n        }\n        if (item.children && updateStatus(item.children)) {\n          return true\n        }\n      }\n      return false\n    }\n\n    updateStatus(fileTree)\n    set({ fileTree: [...fileTree] })\n  },\n\n  allArticle: [],\n  loadAllArticle: async () => {\n    const workspace = await getWorkspacePath()\n    let allArticle: Article[] = []\n    \n    const readDirRecursively = async (dirPath: string, basePath: string, isCustomWorkspace: boolean): Promise<Article[]> => {\n      let allArticles: Article[] = []\n      \n      // 读取当前目录内容\n      const res = isCustomWorkspace \n        ? await readDir(dirPath)\n        : await readDir(dirPath, { baseDir: BaseDirectory.AppData })\n      \n      // 过滤文件\n      const files = res.filter(file => \n        file.isFile && \n        file.name !== '.DS_Store' && \n        !file.name.startsWith('.') && \n        file.name.endsWith('.md')\n      )\n      \n      // 添加文件到结果列表\n      for (const file of files) {\n        // 构建相对路径\n        const relativePath = await join(basePath, file.name)\n        \n        // 读取文件内容\n        let article = ''\n        if (isCustomWorkspace) {\n          const fullPath = await join(dirPath, file.name)\n          article = await readTextFile(fullPath)\n        } else {\n          article = await readTextFile(`${dirPath}/${file.name}`, { baseDir: BaseDirectory.AppData })\n        }\n        \n        allArticles.push({ article, path: relativePath })\n      }\n      \n      // 递归处理子目录\n      const directories = res.filter(entry => \n        entry.isDirectory && \n        !entry.name.startsWith('.')\n      )\n      \n      for (const dir of directories) {\n        const newDirPath = await join(dirPath, dir.name)\n        const newBasePath = await join(basePath, dir.name)\n        const subDirArticles = await readDirRecursively(newDirPath, newBasePath, isCustomWorkspace)\n        allArticles = [...allArticles, ...subDirArticles]\n      }\n      \n      return allArticles\n    }\n\n    if (workspace.isCustom) {\n      // 自定义工作区\n      allArticle = await readDirRecursively(workspace.path, '', true)\n    } else {\n      // 默认工作区\n      allArticle = await readDirRecursively('article', '', false)\n    }\n\n    set({ allArticle })\n  }\n}))\n\nexport default useArticleStore\n"
  },
  {
    "path": "src/stores/chat.ts",
    "content": "import { create } from 'zustand'\nimport { Chat, clearChatsByTagId, deleteChat, initChatsDb, insertChat, updateChat, updateChatsInsertedById, getAllChats, deleteAllChats, insertChats, updateChatCondensedContent, getChatsByConversation } from '@/db/chats'\nimport { uploadFile as uploadGithubFile, getFiles as githubGetFiles, decodeBase64ToString } from '@/lib/sync/github';\nimport { uploadFile as uploadGiteeFile, getFiles as giteeGetFiles } from '@/lib/sync/gitee';\nimport { uploadFile as uploadGitlabFile, getFiles as gitlabGetFiles, getFileContent as gitlabGetFileContent } from '@/lib/sync/gitlab';\nimport { uploadFile as uploadGiteaFile, getFiles as giteaGetFiles, getFileContent as giteaGetFileContent } from '@/lib/sync/gitea';\nimport { s3Upload, s3Delete, s3HeadObject, s3Download } from '@/lib/sync/s3'\nimport { webdavUpload, webdavDelete, webdavHeadObject, webdavDownload } from '@/lib/sync/webdav'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils';\nimport { getRemoteFileContent } from '@/lib/sync/remote-file';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { locales } from '@/lib/locales';\nimport { AgentState, ToolCall } from '@/lib/agent/types'\nimport { LinkedResource } from '@/lib/files'\nimport type { Conversation } from '@/db/conversations'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\n\nexport interface PendingQuote {\n  quote: string\n  fullContent: string\n  fileName: string\n  startLine: number\n  endLine: number\n  from: number\n  to: number\n  articlePath: string\n}\n\n// MCP 工具调用记录（临时，不保存到数据库）\nexport interface McpToolCall {\n  id: string\n  chatId: number // 关联的 chat ID\n  toolName: string\n  serverId: string\n  serverName: string\n  params: Record<string, any>\n  result: string\n  status: 'calling' | 'success' | 'error'\n  timestamp: number\n}\n\ninterface ChatState {\n  loading: boolean\n  setLoading: (loading: boolean) => void\n\n  isCondensing: boolean // 压缩状态\n  _condenseLock: boolean // 内部锁，防止并发压缩\n  maybeCondense: () => void // 触发压缩检查（异步，不阻塞）\n\n  // 兼容旧代码：按标签加载（内部映射到默认会话）\n  chats: Chat[]\n  init: (tagId: number) => Promise<void> // 初始化 chats\n  insert: (chat: Omit<Chat, 'id' | 'createdAt'>) => Promise<Chat | null> // 插入一条 chat\n  updateChat: (chat: Chat) => void // 更新一条 chat\n  saveChat: (chat: Chat, isSave?: boolean) => Promise<void> // 保存一条 chat，用于动态 AI 回复结束后保存数据库\n  deleteChat: (id: number) => Promise<void> // 删除一条 chat\n\n  locale: string\n  getLocale: () => Promise<void>\n  setLocale: (locale: string) => void\n\n  clearChats: (tagId: number) => Promise<void> // 清空 chats（兼容旧代码）\n  updateInsert: (id: number) => Promise<void> // 更新 inserted\n\n  // 同步\n  syncState: boolean\n  setSyncState: (syncState: boolean) => void\n  lastSyncTime: string\n  setLastSyncTime: (lastSyncTime: string) => void\n  uploadChats: () => Promise<boolean>\n  downloadChats: () => Promise<Chat[]>\n\n  // MCP 工具调用记录（临时缓存）\n  mcpToolCalls: McpToolCall[]\n  addMcpToolCall: (toolCall: McpToolCall) => void\n  updateMcpToolCall: (id: string, updates: Partial<McpToolCall>) => void\n  getMcpToolCallsByChatId: (chatId: number) => McpToolCall[]\n  clearMcpToolCalls: () => void\n\n  // Agent 模式\n  agentState: AgentState\n  setAgentState: (state: Partial<AgentState>) => void\n  resetAgentState: () => void\n  addAgentToolCall: (toolCall: ToolCall) => void\n  updateAgentToolCall: (id: string, updates: Partial<ToolCall>) => void\n  agentAutoApproveConversationId: number | null\n  setAgentAutoApproveConversationId: (conversationId: number | null) => void\n  agentAutoApproveRuntimeSkillId: string | null\n  setAgentAutoApproveRuntimeSkillId: (skillId: string | null) => void\n\n  // Placeholder 状态\n  isPlaceholderEnabled: boolean\n  setPlaceholderEnabled: (enabled: boolean) => void\n\n  // 关联的文件或文件夹（用于 Agent 工具调用时判断内容是否已在上下文中）\n  linkedResource: LinkedResource | null\n  setLinkedResource: (resource: LinkedResource | null) => void\n\n  // 关联文件的行号预览（用于 AI 对话时快速了解文件结构）\n  linkedResourcePreview: string | null\n  setLinkedResourcePreview: (preview: string | null) => void\n\n  pendingQuote: PendingQuote | null\n  setPendingQuote: (quote: PendingQuote | null) => void\n  clearPendingQuote: () => void\n\n  onboardingPromptDraft: string | null\n  setOnboardingPromptDraft: (prompt: string | null) => void\n\n  // === 新增：会话管理 ===\n  // 当前会话\n  currentConversationId: number | null\n  conversations: Conversation[]\n\n  // 会话初始化和管理\n  initConversations: () => Promise<void> // 初始化会话列表\n  createConversation: (title?: string) => Promise<number> // 创建新会话\n  switchConversation: (id: number) => Promise<void> // 切换会话\n  updateConversationTitle: (id: number, title: string) => Promise<void> // 更新会话标题\n  deleteConversation: (id: number) => Promise<void> // 删除会话\n  toggleConversationPin: (id: number) => Promise<boolean> // 切换会话置顶状态\n  startNewConversation: () => Promise<void> // 开始新对话（保存当前会话后创建新会话）\n}\n\nconst useChatStore = create<ChatState>((set, get) => ({\n  loading: false,\n\n  setLoading: (loading: boolean) => {\n    set({ loading })\n  },\n\n  isCondensing: false,\n  _condenseLock: false,\n\n  maybeCondense: () => {\n    const state = get()\n\n    // 防并发：已有压缩任务在执行，直接返回\n    if (state._condenseLock) {\n      return\n    }\n\n    // 添加版本号引用，防止竞态条件\n    const versionRef = { current: 0 }\n    const currentVersion = ++versionRef.current\n\n    const { chats } = state\n\n    // 获取最后一次清除后的消息\n    const lastClearIndex = chats.findLastIndex(c => c.type === 'clear')\n    const chatsAfterClear = lastClearIndex === -1 ? chats : chats.slice(lastClearIndex + 1)\n\n    // 使用 IIFE 立即执行异步函数，不等待结果\n    ;(async () => {\n      // 动态导入 condense 模块（避免循环依赖）\n      const { shouldCondense, condenseChats } = await import('@/lib/ai/condense')\n\n      // 版本号检查：防止被新版本覆盖\n      if (currentVersion !== versionRef.current) {\n        return\n      }\n\n      if (!(await shouldCondense(chatsAfterClear))) {\n        return\n      }\n\n      // 再次检查版本号\n      if (currentVersion !== versionRef.current) {\n        return\n      }\n\n      // 设置锁和压缩状态\n      set({ _condenseLock: true, isCondensing: true })\n\n      try {\n        // 为每条消息生成摘要并存储\n        const condensedResults = await condenseChats(chatsAfterClear)\n\n        // 版本号检查：防止在压缩过程中被新版本覆盖\n        if (currentVersion !== versionRef.current) {\n          return\n        }\n\n        for (const result of condensedResults) {\n          if (result.summary) {\n            // 更新数据库中的摘要内容\n            await updateChatCondensedContent(result.chatId, result.summary)\n\n            // 更新 state 中的消息\n            set({\n              chats: get().chats.map(c =>\n                c.id === result.chatId\n                  ? { ...c, condensedContent: result.summary || undefined, condensedAt: Date.now() }\n                  : c\n              )\n            })\n          }\n        }\n      } catch (error) {\n        // 静默失败，不影响用户体验\n        console.error('[ChatStore] 压缩失败:', error)\n      } finally {\n        set({ _condenseLock: false, isCondensing: false })\n      }\n    })()\n  },\n\n  agentState: {\n    activeChatId: undefined,\n    isRunning: false,\n    isThinking: false,\n    currentThought: '',\n    thoughtHistory: [],\n    completedSteps: [],\n    currentAction: undefined,\n    currentObservation: undefined,\n    toolCalls: [],\n    maxIterations: 15,\n    currentIteration: 0,\n    pendingConfirmation: undefined,\n    confirmationHistory: [],\n    loadedSkills: undefined,\n    selectedSkills: undefined,\n    currentStepStartTime: undefined,\n    ragSources: undefined,\n    ragSourceDetails: undefined,\n  },\n\n  setAgentState: (state: Partial<AgentState>) => {\n    set({ agentState: { ...get().agentState, ...state } })\n  },\n\n  resetAgentState: () => {\n    const currentState = get().agentState\n    set({\n      agentState: {\n        activeChatId: undefined,\n        isRunning: false,\n        isThinking: false,\n        currentThought: '',\n        thoughtHistory: [],\n        completedSteps: [],\n        currentAction: '',\n        currentObservation: '',\n        toolCalls: [],\n        maxIterations: 15,\n        currentIteration: 0,\n        pendingConfirmation: undefined,\n        confirmationHistory: [],\n        loadedSkills: undefined,\n        selectedSkills: undefined,\n        currentStepStartTime: undefined,\n        // 保留 RAG 字段，因为它们应该在整个 Agent 执行期间显示\n        ragSources: currentState.ragSources,\n        ragSourceDetails: currentState.ragSourceDetails,\n        // 重置 Final Answer 模式\n        isFinalAnswerMode: false,\n        finalAnswerContent: undefined,\n      }\n    })\n  },\n\n  addAgentToolCall: (toolCall: ToolCall) => {\n    const agentState = get().agentState\n    set({\n      agentState: {\n        ...agentState,\n        toolCalls: [...agentState.toolCalls, toolCall]\n      }\n    })\n  },\n\n  updateAgentToolCall: (id: string, updates: Partial<ToolCall>) => {\n    const agentState = get().agentState\n    set({\n      agentState: {\n        ...agentState,\n        toolCalls: agentState.toolCalls.map(call =>\n          call.id === id ? { ...call, ...updates } : call\n        )\n      }\n    })\n  },\n\n  agentAutoApproveConversationId: null,\n  setAgentAutoApproveConversationId: (conversationId: number | null) => {\n    set({ agentAutoApproveConversationId: conversationId })\n  },\n  agentAutoApproveRuntimeSkillId: null,\n  setAgentAutoApproveRuntimeSkillId: (skillId: string | null) => {\n    set({ agentAutoApproveRuntimeSkillId: skillId })\n  },\n\n  isPlaceholderEnabled: true,\n  setPlaceholderEnabled: (enabled: boolean) => {\n    set({ isPlaceholderEnabled: enabled })\n  },\n\n  linkedResource: null,\n  setLinkedResource: (resource: LinkedResource | null) => {\n    set({ linkedResource: resource })\n  },\n\n  linkedResourcePreview: null,\n  setLinkedResourcePreview: (preview: string | null) => {\n    set({ linkedResourcePreview: preview })\n  },\n\n  pendingQuote: null,\n  setPendingQuote: (pendingQuote: PendingQuote | null) => {\n    set({ pendingQuote })\n  },\n  clearPendingQuote: () => {\n    set({ pendingQuote: null })\n  },\n\n  onboardingPromptDraft: null,\n  setOnboardingPromptDraft: (prompt: string | null) => {\n    set({ onboardingPromptDraft: prompt })\n  },\n\n  chats: [],\n  // 兼容旧代码：init 方法现在会初始化会话列表并切换到第一个会话\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  init: async (_tagId: number) => {\n    await initChatsDb()\n    // 先初始化会话列表\n    await get().initConversations()\n\n    const { currentConversationId, conversations } = get()\n\n    // 如果没有当前会话\n    if (!currentConversationId) {\n      if (conversations.length > 0) {\n        // 有历史会话，切换到第一个\n        await get().switchConversation(conversations[0].id)\n      }\n      // 如果没有历史会话，保持空状态，不创建新会话\n    } else {\n      // 加载当前会话的聊天记录\n      const data = await getChatsByConversation(currentConversationId)\n      set({ chats: data })\n    }\n  },\n  insert: async (chat) => {\n    const { currentConversationId } = get()\n\n    // 确保有 conversationId，如果没有则创建新会话\n    let conversationId = chat.conversationId || currentConversationId\n    if (!conversationId) {\n      // 没有当前会话，创建一个新会话\n      const { createConversation } = await import('@/db/conversations')\n      conversationId = await createConversation('新对话')\n      // 设置为当前会话并刷新会话列表\n      set({ currentConversationId: conversationId })\n      await get().initConversations()\n    }\n\n    const res = await insertChat({ ...chat, conversationId })\n    let data: Chat\n    if (res.lastInsertId) {\n      data =  {\n        id: res.lastInsertId,\n        createdAt: Date.now(),\n        ...chat,\n        conversationId\n      }\n      const chats = get().chats\n      const newChats = [...chats, data]\n      set({ chats: newChats })\n\n      // 更新会话的消息数量和更新时间\n      if (conversationId) {\n        const { updateConversationMessageCount, updateConversationTime, updateConversationTitle, getConversation } = await import('@/db/conversations')\n        await updateConversationMessageCount(conversationId, 1)\n        await updateConversationTime(conversationId)\n\n        // 如果是当前会话的第一条用户消息，用消息内容作为标题\n        // 从数据库获取最新的会话状态，而不是使用内存中的旧数据\n        const currentConv = await getConversation(conversationId)\n        if (currentConv && currentConv.messageCount === 1 && chat.role === 'user' && chat.content) {\n          // 直接使用用户输入的前30个字符作为标题\n          const title = chat.content\n            .replace(/\\n/g, ' ')  // 移除换行符\n            .trim()\n            .slice(0, 30)\n\n          if (title && title !== currentConv.title) {\n            await updateConversationTitle(conversationId, title)\n          }\n        }\n\n        // 刷新会话列表\n        await get().initConversations()\n      }\n\n      return data\n    }\n    return null\n  },\n  updateChat: (chat) => {\n    const chats = get().chats\n    const newChats = chats.map(item => {\n      if (item.id === chat.id) {\n        // 合并更新，只覆盖非 undefined 的字段，保留已存在的字段（如 ragSources）\n        const result = { ...item }\n        for (const key in chat) {\n          if ((chat as any)[key] !== undefined) {\n            (result as any)[key] = (chat as any)[key]\n          }\n        }\n        return result\n      }\n      return item\n    })\n    set({ chats: newChats })\n  },\n  saveChat: async (chat, isSave = false) => {\n    get().updateChat(chat)\n    if (isSave) {\n      await updateChat(chat)\n    }\n  },\n  deleteChat: async (id) => {\n    const chats = get().chats\n    const newChats = chats.filter(item => item.id !== id)\n    set({ chats: newChats })\n    await deleteChat(id)\n\n    // 更新会话的消息数量\n    const { currentConversationId } = get()\n    if (currentConversationId) {\n      const { updateConversationMessageCount } = await import('@/db/conversations')\n      await updateConversationMessageCount(currentConversationId, -1)\n      await get().initConversations()\n    }\n  },\n\n\n  locale: locales[0],\n  getLocale: async () => {\n    const store = await Store.load('store.json');\n    const res = (await store.get<string>('note_locale')) || locales[0]\n    set({ locale: res })\n  },\n  setLocale: async (locale) => {\n    set({ locale })\n    const store = await Store.load('store.json');\n    await store.set('note_locale', locale)\n  },\n\n  // 兼容旧代码：clearChats 现在会清空当前会话的聊天记录\n  clearChats: async (tagId) => {\n    set({ chats: [] })\n    // 清空聊天记录时同步清理 Agent 状态\n    get().resetAgentState()\n    get().clearMcpToolCalls()\n    get().clearPendingQuote()\n\n    // 更新会话的消息数量\n    const { currentConversationId } = get()\n    if (currentConversationId) {\n      // 获取当前消息数量\n      const { chats } = get()\n      const count = chats.length\n\n      // 删除数据库中的记录\n      const db = await import('@/db').then(m => m.getDb())\n      await db.execute(\"delete from chats where conversationId = $1\", [currentConversationId])\n\n      const { updateConversationMessageCount } = await import('@/db/conversations')\n      await updateConversationMessageCount(currentConversationId, -count)\n      await get().initConversations()\n    } else {\n      // 兼容旧代码：如果没有 conversationId，使用 tagId\n      await clearChatsByTagId(tagId)\n    }\n  },\n\n  updateInsert: async (id) => {\n    await updateChatsInsertedById(id)\n    const chats = get().chats\n    const newChats = chats.map(item => {\n      if (item.id === id) {\n        item.inserted = true\n      }\n      return item\n    })\n    set({ chats: newChats })\n  },\n\n  // 同步\n  syncState: false,\n  setSyncState: (syncState) => {\n    set({ syncState })\n  },\n  lastSyncTime: '',\n  setLastSyncTime: (lastSyncTime) => {\n    set({ lastSyncTime })\n  },\n  uploadChats: async () => {\n    set({ syncState: true })\n    const path = '.data'\n    const filename = 'chats.json'\n    const chats = await getAllChats()\n    const store = await Store.load('store.json');\n    const jsonToBase64 = (data: Chat[]) => {\n      return Buffer.from(JSON.stringify(data, null, 2)).toString('base64');\n    }\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let result = false\n    let files: any;\n    let res;\n    const fullPath = `${path}/${filename}`;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: fullPath, repo: githubRepo })\n        res = await uploadGithubFile({\n          file: jsonToBase64(chats),\n          repo: githubRepo,\n          path: fullPath,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: fullPath, repo: giteeRepo })\n        res = await uploadGiteeFile({\n          file: jsonToBase64(chats),\n          repo: giteeRepo,\n          path: fullPath,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitlab':\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        files = await gitlabGetFiles({ path, repo: gitlabRepo })\n        const chatFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGitlabFile({\n          file: jsonToBase64(chats),\n          repo: gitlabRepo,\n          path,\n          filename,\n          sha: chatFile?.sha || '',\n        })\n        break;\n      case 'gitea':\n        const giteaRepo = await getSyncRepoName('gitea')\n        files = await giteaGetFiles({ path, repo: giteaRepo })\n        const giteaChatFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGiteaFile({\n          file: jsonToBase64(chats),\n          repo: giteaRepo,\n          path,\n          filename,\n          sha: giteaChatFile?.sha || '',\n        })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const existingFile = await s3HeadObject(s3Config, s3Key)\n          if (existingFile) {\n            await s3Delete(s3Config, s3Key)\n          }\n          res = await s3Upload(s3Config, s3Key, JSON.stringify(chats, null, 2))\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const existingFile = await webdavHeadObject(webdavConfig, webdavKey)\n          if (existingFile) {\n            await webdavDelete(webdavConfig, webdavKey)\n          }\n          res = await webdavUpload(webdavConfig, webdavKey, JSON.stringify(chats, null, 2))\n        }\n        break;\n      }\n    }\n    if (res) {\n      result = true\n    }\n    set({ syncState: false })\n    return result\n  },\n  // MCP 工具调用记录\n  mcpToolCalls: [],\n\n  addMcpToolCall: (toolCall: McpToolCall) => {\n    const mcpToolCalls = get().mcpToolCalls\n    set({ mcpToolCalls: [...mcpToolCalls, toolCall] })\n  },\n\n  updateMcpToolCall: (id: string, updates: Partial<McpToolCall>) => {\n    const mcpToolCalls = get().mcpToolCalls.map(call =>\n      call.id === id ? { ...call, ...updates } : call\n    )\n    set({ mcpToolCalls })\n  },\n\n  getMcpToolCallsByChatId: (chatId: number) => {\n    return get().mcpToolCalls.filter(call => call.chatId === chatId)\n  },\n\n  clearMcpToolCalls: () => {\n    set({ mcpToolCalls: [] })\n  },\n\n  downloadChats: async () => {\n    const path = '.data'\n    const filename = 'chats.json'\n    const store = await Store.load('store.json');\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let result = []\n    let files;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo2 = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: `${path}/${filename}`, repo: githubRepo2 })\n        break;\n      case 'gitee':\n        const giteeRepo2 = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: `${path}/${filename}`, repo: giteeRepo2 })\n        break;\n      case 'gitlab':\n        const gitlabRepo2 = await getSyncRepoName('gitlab')\n        files = await gitlabGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: gitlabRepo2 })\n        break;\n      case 'gitea':\n        const giteaRepo2 = await getSyncRepoName('gitea')\n        files = await giteaGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: giteaRepo2 })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const s3Result = await s3Download(s3Config, s3Key)\n          if (s3Result) {\n            // S3 返回的 content 是字符串，直接解析\n            result = JSON.parse(s3Result.content)\n          }\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const webdavResult = await webdavDownload(webdavConfig, webdavKey)\n          if (webdavResult) {\n            result = JSON.parse(webdavResult.content)\n          }\n        }\n        break;\n      }\n    }\n    // S3/WebDAV 已经直接解析到 result 了，这里处理 Git 平台\n    if (files) {\n      const configJson = decodeBase64ToString(getRemoteFileContent(files, `${path}/${filename}`))\n      result = JSON.parse(configJson)\n    }\n    if (result.length > 0) {\n      await deleteAllChats()\n      await insertChats(result)\n    }\n    set({ syncState: false })\n    return result\n  },\n\n  // === 新增：会话管理方法 ===\n  currentConversationId: null,\n  conversations: [],\n\n  initConversations: async () => {\n    const { getAllConversations } = await import('@/db/conversations')\n    const conversations = await getAllConversations()\n    set({ conversations })\n  },\n\n  createConversation: async (title = '新对话') => {\n    const { createConversation: createConv } = await import('@/db/conversations')\n    const id = await createConv(title)\n    // 设置为当前会话并刷新会话列表\n    set({ currentConversationId: id })\n    await get().initConversations()\n    return id\n  },\n\n  switchConversation: async (id: number) => {\n    // 先同步消息数量，确保 messageCount 与实际消息数量一致\n    const { syncConversationMessageCount } = await import('@/db/conversations')\n    await syncConversationMessageCount(id)\n    // 然后加载消息\n    const { getChatsByConversation } = await import('@/db/chats')\n    const data = await getChatsByConversation(id)\n    set({ currentConversationId: id, chats: data, pendingQuote: null })\n    // 刷新会话列表以确保 UI 显示最新的会话状态\n    await get().initConversations()\n  },\n\n  updateConversationTitle: async (id: number, title: string) => {\n    const { updateConversationTitle: updateTitle } = await import('@/db/conversations')\n    await updateTitle(id, title)\n    // 刷新会话列表\n    await get().initConversations()\n  },\n\n  deleteConversation: async (id: number) => {\n    const { deleteConversation: deleteConv } = await import('@/db/conversations')\n    await deleteConv(id)\n\n    const { currentConversationId, conversations, switchConversation } = get()\n\n    // 如果删除的是当前会话，切换到另一个会话\n    if (id === currentConversationId) {\n      const remainingConversations = conversations.filter(c => c.id !== id)\n      if (remainingConversations.length > 0) {\n        await switchConversation(remainingConversations[0].id)\n      } else {\n        // 没有其他会话了，清空状态，不创建新会话\n        set({\n          currentConversationId: null,\n          chats: [],\n          pendingQuote: null,\n          agentAutoApproveConversationId: null,\n          agentAutoApproveRuntimeSkillId: null\n        })\n        get().resetAgentState()\n        get().clearMcpToolCalls()\n      }\n    }\n\n    // 刷新会话列表\n    await get().initConversations()\n  },\n\n  toggleConversationPin: async (id: number) => {\n    const { toggleConversationPin: togglePin } = await import('@/db/conversations')\n    const isPinned = await togglePin(id)\n    // 刷新会话列表\n    await get().initConversations()\n    return isPinned\n  },\n\n  startNewConversation: async () => {\n    const { currentConversationId } = get()\n\n    // 如果当前会话无消息，删除它（从数据库查询最新状态）\n    if (currentConversationId) {\n      const { getConversation } = await import('@/db/conversations')\n      const currentConv = await getConversation(currentConversationId)\n      if (currentConv && currentConv.messageCount === 0) {\n        // 空会话，直接删除\n        const { deleteConversation: deleteConv } = await import('@/db/conversations')\n        await deleteConv(currentConversationId)\n      }\n      // 刷新会话列表\n      await get().initConversations()\n    }\n\n    // 清空聊天，不立即创建新会话\n    // 等到用户发送第一条消息时才创建会话\n    set({\n      currentConversationId: null,\n      chats: [],\n      pendingQuote: null,\n      agentAutoApproveConversationId: null,\n      agentAutoApproveRuntimeSkillId: null\n    })\n    // 清空 Agent 状态\n    get().resetAgentState()\n    get().clearMcpToolCalls()\n  },\n}))\n\nexport default useChatStore\n"
  },
  {
    "path": "src/stores/clipboard.ts",
    "content": "import { create } from 'zustand'\n\ninterface ClipboardItem {\n  path: string\n  name: string\n  isDirectory: boolean\n  sha?: string\n  isLocale?: boolean\n}\n\ntype ClipboardOperation = 'copy' | 'cut' | 'none'\n\ninterface ClipboardState {\n  clipboardItem: ClipboardItem | null\n  clipboardOperation: ClipboardOperation\n  setClipboardItem: (item: ClipboardItem | null, operation: ClipboardOperation) => void\n}\n\nconst useClipboardStore = create<ClipboardState>((set) => ({\n  clipboardItem: null,\n  clipboardOperation: 'none',\n  setClipboardItem: (item, operation) => set({ clipboardItem: item, clipboardOperation: operation }),\n}))\n\nexport default useClipboardStore\n"
  },
  {
    "path": "src/stores/imageHosting.ts",
    "content": "import { GithubFile } from '@/lib/sync/github';\nimport { getImageFiles } from '@/lib/imageHosting/github';\nimport { GithubRepoInfo, OctokitResponse, SyncStateEnum, UserInfo } from '@/lib/sync/github.types';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { create } from 'zustand'\n\ninterface S3Config {\n  accessKeyId: string\n  secretAccessKey: string\n  region: string\n  bucket: string\n  endpoint?: string\n  customDomain?: string\n  pathPrefix?: string\n}\n\ninterface MarkState {\n  initMainHosting: () => Promise<void>\n  path: string\n  setPath: (path: string) => void\n\n  images: GithubFile[]\n  pushImage: (image: GithubFile) => void\n  deleteImage: (name: string) => void\n  getImages: () => Promise<void>\n\n  // 主要图床\n  mainImageHosting: string\n  setMainImageHosting: (mainImageHosting: string) => Promise<void>\n  \n  // 图床 Github 仓库\n  imageRepoUserInfo?: OctokitResponse<UserInfo>\n  setImageRepoUserInfo: (imageRepoUserInfo?: OctokitResponse<UserInfo>) => Promise<void>\n  imageRepoState: SyncStateEnum\n  setImageRepoState: (imageRepoState: SyncStateEnum) => void\n  imageRepoInfo?: GithubRepoInfo\n  setImageRepoInfo: (imageRepoInfo?: GithubRepoInfo) => void\n\n  // S3 配置\n  s3Config?: S3Config\n  setS3Config: (config: S3Config) => Promise<void>\n  s3State: SyncStateEnum\n  setS3State: (state: SyncStateEnum) => void\n}\n\nconst useImageStore = create<MarkState>((set, get) => ({\n  initMainHosting: async () => {\n    const store = await Store.load('store.json');\n    const mainImageHosting = await store.get<string>('mainImageHosting')\n    if (mainImageHosting) {\n      set({ mainImageHosting })\n    }\n\n    // 初始化 S3 配置\n    const s3Config = await store.get<S3Config>('s3Config');\n    if (s3Config) {\n      set({ s3Config })\n    }\n  },\n  path: '',\n  setPath: (path) => set({ path }),\n\n  images: [],\n\n  pushImage: (image) => {\n    set(state => ({\n      images: [image, ...state.images]\n    }))\n  },\n  deleteImage: (name) => {\n    set(state => ({\n      images: state.images.filter(item => item.name !== name)\n    }))\n  },\n  async getImages() {\n    set({ images: [] })\n    const images = await getImageFiles({ path: get().path })\n    set({ images: images || [] })\n  },\n\n  // 主要图床\n  mainImageHosting: 'github',\n  setMainImageHosting: async (mainImageHosting) => {\n    set({ mainImageHosting })\n    const store = await Store.load('store.json');\n    await store.set('mainImageHosting', mainImageHosting)\n    await store.save()\n  },\n\n  imageRepoUserInfo: undefined,\n  setImageRepoUserInfo: async (imageRepoUserInfo) => {\n    set({ imageRepoUserInfo })\n    if (!imageRepoUserInfo) return\n    const store = await Store.load('store.json');\n    await store.set('githubImageUsername', imageRepoUserInfo?.data?.login)\n    await store.save()\n  },\n  imageRepoState: SyncStateEnum.fail,\n  setImageRepoState: (imageRepoState) => {\n    set({ imageRepoState })\n  },\n  imageRepoInfo: undefined,\n  setImageRepoInfo: (imageRepoInfo) => {\n    set({ imageRepoInfo })\n  },\n\n  // S3 配置\n  s3Config: undefined,\n  setS3Config: async (config) => {\n    set({ s3Config: config })\n    const store = await Store.load('store.json');\n    await store.set('s3Config', config)\n    await store.save()\n  },\n  s3State: SyncStateEnum.fail,\n  setS3State: (s3State) => {\n    set({ s3State })\n  },\n}))\n\nexport default useImageStore"
  },
  {
    "path": "src/stores/mark.ts",
    "content": "import { deleteAllMarks, getAllMarks, getMarks, insertMarks, Mark, updateMark } from '@/db/marks'\nimport { uploadFile as uploadGithubFile, getFiles as githubGetFiles, decodeBase64ToString } from '@/lib/sync/github';\nimport { uploadFile as uploadGiteeFile, getFiles as giteeGetFiles } from '@/lib/sync/gitee';\nimport { uploadFile as uploadGitlabFile, getFiles as gitlabGetFiles, getFileContent as gitlabGetFileContent } from '@/lib/sync/gitlab';\nimport { uploadFile as uploadGiteaFile, getFiles as giteaGetFiles, getFileContent as giteaGetFileContent } from '@/lib/sync/gitea';\nimport { s3Upload, s3Delete, s3HeadObject, s3Download } from '@/lib/sync/s3'\nimport { webdavUpload, webdavDelete, webdavHeadObject, webdavDownload } from '@/lib/sync/webdav'\nimport { WebDAVConfig } from '@/types/sync'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils';\nimport { getRemoteFileContent } from '@/lib/sync/remote-file';\nimport { Store } from '@tauri-apps/plugin-store';\nimport { create } from 'zustand'\nimport { S3Config } from '@/types/sync'\nimport { normalizeRecordFilters } from '@/app/core/main/mark/mark-filters.mjs'\nimport { normalizeRecordViewMode } from '@/app/core/main/mark/mark-view-mode.mjs'\n\nexport interface MarkQueue {\n  queueId: string\n  tagId: number\n  type: Mark[\"type\"]\n  progress: string\n  startTime: number\n}\n\nexport type RecordTimePreset = 'all' | 'today' | 'last7Days' | 'last30Days'\nexport type RecordViewMode = 'list' | 'compact' | 'cards'\n\nexport interface RecordFilters {\n  search: string\n  selectedTypes: Mark[\"type\"][]\n  timePreset: RecordTimePreset\n  tagId: number | 'all'\n}\n\nconst DEFAULT_RECORD_FILTERS: RecordFilters = {\n  search: '',\n  selectedTypes: [],\n  timePreset: 'all',\n  tagId: 'all',\n}\n\nasync function persistRecordFilters(recordFilters: RecordFilters) {\n  const store = await Store.load('store.json')\n  await store.set('recordFilters', recordFilters)\n}\n\nasync function persistRecordViewMode(recordViewMode: RecordViewMode) {\n  const store = await Store.load('store.json')\n  await store.set('recordViewMode', recordViewMode)\n}\n\ninterface MarkState {\n  trashState: boolean\n  setTrashState: (flag: boolean) => void\n\n  marks: Mark[]\n  updateMark: (mark: Mark) => Promise<void>\n  setMarks: (marks: Mark[]) => void\n  fetchMarks: () => Promise<void>\n  fetchAllTrashMarks: () => Promise<void>\n\n  allMarks: Mark[]\n  fetchAllMarks: () => Promise<void>\n\n  queues: MarkQueue[]\n  addQueue: (mark: MarkQueue) => void\n  setQueue: (queueId: string, mark: Partial<MarkQueue>) => void\n  removeQueue: (queueId: string) => void\n\n  // 多选状态\n  selectedMarkIds: Set<number>\n  setSelectedMarkIds: (ids: Set<number>) => void\n  toggleMarkSelection: (id: number) => void\n  clearSelection: () => void\n  selectAll: () => void\n  isMultiSelectMode: boolean\n  setMultiSelectMode: (mode: boolean) => void\n  visibleMarkIds: number[]\n  setVisibleMarkIds: (ids: number[]) => void\n  pendingScrollMarkId: number | null\n  setPendingScrollMarkId: (id: number | null) => void\n  highlightedMarkId: number | null\n  setHighlightedMarkId: (id: number | null) => void\n\n  recordFilters: RecordFilters\n  setRecordSearch: (search: string) => void\n  toggleRecordType: (type: Mark[\"type\"]) => void\n  setRecordTimePreset: (preset: RecordTimePreset) => void\n  setRecordTagId: (tagId: number | 'all') => void\n  resetRecordFilters: () => void\n  hasActiveRecordFilters: () => boolean\n  initRecordFilters: () => Promise<void>\n\n  recordViewMode: RecordViewMode\n  setRecordViewMode: (mode: RecordViewMode) => void\n  initRecordViewMode: () => Promise<void>\n\n  // 同步\n  syncState: boolean\n  setSyncState: (syncState: boolean) => void\n  lastSyncTime: string\n  setLastSyncTime: (lastSyncTime: string) => void\n  uploadMarks: () => Promise<boolean>\n  downloadMarks: () => Promise<Mark[]>\n}\n\nconst useMarkStore = create<MarkState>((set, get) => ({\n  trashState: false,\n  setTrashState: (flag) => {\n    set({ trashState: flag })\n  },\n\n  marks: [],\n  updateMark: async (mark) => {\n    set((state) => {\n      return {\n        marks: state.marks.map(item => {\n          if (item.id === mark.id) {\n            return {\n              ...item,\n              ...mark\n            }\n          }\n          return item\n        })\n      }\n    })\n    await updateMark(mark)\n  },\n  setMarks: (marks) => {\n    set({ marks })\n  },\n  fetchMarks: async () => {\n    const store = await Store.load('store.json');\n    const currentTagId = await store.get<number>('currentTagId')\n    if (!currentTagId) {\n      return\n    }\n    const res = await getMarks(currentTagId)\n    const decodeRes = res.map(item => {\n      return {\n        ...item,\n        content: item.content || ''\n      }\n    }).filter((item) => item.deleted === 0)\n    set({ marks: decodeRes })\n  },\n  fetchAllTrashMarks: async () => {\n    const res = await getAllMarks()\n    const decodeRes = res.map(item => {\n      return {\n        ...item,\n        content: item.content || ''\n      }\n    }).filter((item) => item.deleted === 1)\n    set({ marks: decodeRes })\n  },\n\n  allMarks: [],\n  fetchAllMarks: async () => {\n    const res = await getAllMarks()\n    const decodeRes = res.map(item => {\n      return {\n        ...item,\n        content: item.content || ''\n      }\n    }).filter((item) => item.deleted === 0)\n    set({ allMarks: decodeRes })\n  },\n\n  queues: [],\n  addQueue: (mark) => {\n    set((state) => {\n      return {\n        queues: [mark, ...state.queues]\n      }\n    })\n  },\n  setQueue: (queueId, mark) => {\n    set((state) => {\n      return {\n        queues: state.queues.map(item => {\n          if (item.queueId === queueId) {\n            return {\n              ...item,\n              ...mark\n            }\n          }\n          return item\n        })\n      }\n    })\n  },\n  removeQueue: (queueId) => {\n    set((state) => {\n      return {\n        queues: state.queues.filter(item => item.queueId !== queueId)\n      }\n    })\n  },\n\n  // 多选状态\n  selectedMarkIds: new Set<number>(),\n  setSelectedMarkIds: (ids) => {\n    set({ selectedMarkIds: ids })\n  },\n  toggleMarkSelection: (id) => {\n    set((state) => {\n      const newSelectedIds = new Set(state.selectedMarkIds)\n      if (newSelectedIds.has(id)) {\n        newSelectedIds.delete(id)\n      } else {\n        newSelectedIds.add(id)\n      }\n      return { selectedMarkIds: newSelectedIds }\n    })\n  },\n  clearSelection: () => {\n    set({ selectedMarkIds: new Set<number>(), isMultiSelectMode: false })\n  },\n  selectAll: () => {\n    const { marks, visibleMarkIds } = get()\n    const ids = visibleMarkIds.length > 0 ? visibleMarkIds : marks.map(mark => mark.id)\n    const allIds = new Set(ids)\n    set({ selectedMarkIds: allIds, isMultiSelectMode: true })\n  },\n  isMultiSelectMode: false,\n  setMultiSelectMode: (mode) => {\n    set({ isMultiSelectMode: mode })\n    if (!mode) {\n      set({ selectedMarkIds: new Set<number>() })\n    }\n  },\n  visibleMarkIds: [],\n  setVisibleMarkIds: (ids) => {\n    set({ visibleMarkIds: ids })\n  },\n  pendingScrollMarkId: null,\n  setPendingScrollMarkId: (id) => {\n    set({ pendingScrollMarkId: id })\n  },\n  highlightedMarkId: null,\n  setHighlightedMarkId: (id) => {\n    set({ highlightedMarkId: id })\n  },\n\n  recordFilters: DEFAULT_RECORD_FILTERS,\n  setRecordSearch: (search) => {\n    set((state) => {\n      const recordFilters = {\n        ...state.recordFilters,\n        search,\n      }\n      void persistRecordFilters(recordFilters)\n      return { recordFilters }\n    })\n  },\n  toggleRecordType: (type) => {\n    set((state) => {\n      const selectedTypes = state.recordFilters.selectedTypes.includes(type)\n        ? state.recordFilters.selectedTypes.filter((item) => item !== type)\n        : [...state.recordFilters.selectedTypes, type]\n\n      const recordFilters = {\n        ...state.recordFilters,\n        selectedTypes,\n      }\n      void persistRecordFilters(recordFilters)\n\n      return {\n        recordFilters,\n      }\n    })\n  },\n  setRecordTimePreset: (preset) => {\n    set((state) => {\n      const recordFilters = {\n        ...state.recordFilters,\n        timePreset: preset,\n      }\n      void persistRecordFilters(recordFilters)\n      return { recordFilters }\n    })\n  },\n  setRecordTagId: (tagId) => {\n    set((state) => {\n      const recordFilters = {\n        ...state.recordFilters,\n        tagId,\n      }\n      void persistRecordFilters(recordFilters)\n      return { recordFilters }\n    })\n  },\n  resetRecordFilters: () => {\n    void persistRecordFilters(DEFAULT_RECORD_FILTERS)\n    set({\n      recordFilters: DEFAULT_RECORD_FILTERS,\n    })\n  },\n  hasActiveRecordFilters: () => {\n    const { recordFilters } = get()\n    return Boolean(\n      recordFilters.search.trim() ||\n      recordFilters.selectedTypes.length > 0 ||\n      recordFilters.timePreset !== 'all' ||\n      recordFilters.tagId !== 'all'\n    )\n  },\n  initRecordFilters: async () => {\n    const store = await Store.load('store.json')\n    const savedFilters = await store.get<RecordFilters>('recordFilters')\n    set({\n      recordFilters: normalizeRecordFilters(savedFilters),\n    })\n  },\n\n  recordViewMode: 'list',\n  setRecordViewMode: (mode) => {\n    const recordViewMode = normalizeRecordViewMode(mode) as RecordViewMode\n    void persistRecordViewMode(recordViewMode)\n    set({ recordViewMode })\n  },\n  initRecordViewMode: async () => {\n    const store = await Store.load('store.json')\n    const savedRecordViewMode = await store.get<RecordViewMode>('recordViewMode')\n    const recordViewMode = normalizeRecordViewMode(savedRecordViewMode) as RecordViewMode\n    if (savedRecordViewMode !== recordViewMode) {\n      await store.set('recordViewMode', recordViewMode)\n    }\n    set({ recordViewMode })\n  },\n\n  // 同步\n  syncState: false,\n  setSyncState: (syncState) => {\n    set({ syncState })\n  },\n  lastSyncTime: '',\n  setLastSyncTime: (lastSyncTime) => {\n    set({ lastSyncTime })\n  },\n  uploadMarks: async () => {\n    set({ syncState: true })\n    const path = '.data'\n    const filename = 'marks.json'\n    const marks = await getAllMarks()\n    console.log('[mark store] uploadMarks - marks count:', marks.length)\n    const store = await Store.load('store.json');\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    console.log('[mark store] uploadMarks - primaryBackupMethod:', primaryBackupMethod)\n    let result = false\n    let files: any;\n    let res;\n    const fullPath = `${path}/${filename}`;\n    try {\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepoName = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: fullPath, repo: githubRepoName })\n        res = await uploadGithubFile({\n          file: JSON.stringify(marks),\n          repo: githubRepoName,\n          path: fullPath,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitee':\n        const giteeRepoName = await getSyncRepoName('gitee')\n        try {\n          files = await giteeGetFiles({ path: fullPath, repo: giteeRepoName })\n          const sha = files?.sha\n          res = await uploadGiteeFile({\n            file: JSON.stringify(marks),\n            repo: giteeRepoName,\n            path: fullPath,\n            sha: sha,\n          })\n        } catch (err) {\n          console.error('[mark store] Gitee upload error:', err)\n        }\n        if (res) {\n          result = true\n        }\n        break;\n      case 'gitlab': {\n        const gitlabRepoName = await getSyncRepoName('gitlab')\n        console.log('[mark store] GitLab upload - path:', path, 'filename:', filename, 'repo:', gitlabRepoName)\n        try {\n          files = await gitlabGetFiles({ path, repo: gitlabRepoName })\n        } catch (e) {\n          console.error('[mark store] GitLab getFiles error:', e)\n        }\n        console.log('[mark store] GitLab files:', files)\n\n        // 如果目录不存在（files 为 null），先创建目录标记文件\n        if (!files) {\n          console.log('[mark store] GitLab directory does not exist, creating .gitkeep')\n          try {\n            await uploadGitlabFile({\n              file: '',\n              repo: gitlabRepoName,\n              path,\n              filename: '.gitkeep',\n              sha: '',\n            })\n          } catch (e) {\n            console.log('[mark store] GitLab create .gitkeep error:', e)\n          }\n          // 重新获取文件列表\n          files = await gitlabGetFiles({ path, repo: gitlabRepoName })\n        }\n\n        const markFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        console.log('[mark store] GitLab markFile:', markFile)\n        try {\n          res = await uploadGitlabFile({\n            file: JSON.stringify(marks),\n            repo: gitlabRepoName,\n            path,\n            filename,\n            sha: markFile?.sha || '',\n          })\n        } catch (e) {\n          console.error('[mark store] GitLab uploadFile error:', e)\n        }\n        console.log('[mark store] GitLab upload result:', res)\n        break;\n      }\n      case 'gitea':\n        const giteaRepoName = await getSyncRepoName('gitea')\n        files = await giteaGetFiles({ path, repo: giteaRepoName })\n        const giteaMarkFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGiteaFile({\n          file: JSON.stringify(marks),\n          repo: giteaRepoName,\n          path,\n          filename,\n          sha: giteaMarkFile?.sha || '',\n        })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const existingFile = await s3HeadObject(s3Config, s3Key)\n          if (existingFile) {\n            await s3Delete(s3Config, s3Key)\n          }\n          res = await s3Upload(s3Config, s3Key, JSON.stringify(marks))\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const existingFile = await webdavHeadObject(webdavConfig, webdavKey)\n          if (existingFile) {\n            await webdavDelete(webdavConfig, webdavKey)\n          }\n          res = await webdavUpload(webdavConfig, webdavKey, JSON.stringify(marks))\n        }\n        break;\n      }\n    }\n    } catch (error) {\n      console.error('[mark store] uploadMarks error:', error)\n    }\n    if (res) {\n      result = true\n    }\n    set({ syncState: false })\n    return result\n  },\n  downloadMarks: async () => {\n    const path = '.data'\n    const filename = 'marks.json'\n    const store = await Store.load('store.json');\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let result = []\n    let files;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepoName = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: `${path}/${filename}`, repo: githubRepoName })\n        break;\n      case 'gitee':\n        const giteeRepoName = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: `${path}/${filename}`, repo: giteeRepoName })\n        break;\n      case 'gitlab':\n        const gitlabRepoName = await getSyncRepoName('gitlab')\n        files = await gitlabGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: gitlabRepoName })\n        break;\n      case 'gitea':\n        const giteaRepoName = await getSyncRepoName('gitea')\n        files = await giteaGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: giteaRepoName })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const s3Result = await s3Download(s3Config, s3Key)\n          if (s3Result) {\n            // S3 返回的 content 是字符串，直接解析\n            result = JSON.parse(s3Result.content)\n          }\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const webdavResult = await webdavDownload(webdavConfig, webdavKey)\n          if (webdavResult) {\n            result = JSON.parse(webdavResult.content)\n          }\n        }\n        break;\n      }\n    }\n    // S3 已经直接解析到 result 了，这里处理 Git 平台\n    if (files) {\n      const configJson = decodeBase64ToString(getRemoteFileContent(files, `${path}/${filename}`))\n      result = JSON.parse(configJson)\n    }\n    if (result.length > 0) {\n      await deleteAllMarks()\n      await insertMarks(result)\n    }\n    set({ syncState: false })\n    return result\n  },\n}))\n\nexport default useMarkStore\n"
  },
  {
    "path": "src/stores/mcp.ts",
    "content": "import { create } from 'zustand'\nimport { Store } from '@tauri-apps/plugin-store'\nimport type { MCPServerConfig, MCPServerState } from '@/lib/mcp/types'\n\ninterface MCPState {\n  // 服务器配置列表\n  servers: MCPServerConfig[]\n\n  // 服务器运行时状态\n  serverStates: Map<string, MCPServerState>\n\n  // 当前选中的服务器（用于对话）\n  selectedServerIds: string[]\n\n  // 是否已初始化\n  initialized: boolean\n\n  // 服务器管理\n  addServer: (server: MCPServerConfig) => void\n  updateServer: (id: string, updates: Partial<MCPServerConfig>) => void\n  deleteServer: (id: string) => void\n  toggleServerEnabled: (id: string) => void\n\n  // 服务器状态管理\n  setServerState: (id: string, state: MCPServerState) => void\n  getServerState: (id: string) => MCPServerState | undefined\n\n  // 选中服务器管理\n  setSelectedServers: (ids: string[]) => void\n  toggleServerSelection: (id: string) => void\n  clearSelectedServers: () => void\n\n  // 初始化\n  initMcpData: () => Promise<void>\n  loadMcpConfig: () => Promise<void>\n}\n\nexport const useMcpStore = create<MCPState>((set, get) => ({\n  servers: [],\n  serverStates: new Map(),\n  selectedServerIds: [],\n  initialized: false,\n  \n  addServer: async (server: MCPServerConfig) => {\n    const store = await Store.load('store.json')\n    const servers = [...get().servers, server]\n    await store.set('mcp.servers', servers)\n    await store.save()\n    set({ servers })\n  },\n  \n  updateServer: async (id: string, updates: Partial<MCPServerConfig>) => {\n    const store = await Store.load('store.json')\n    const servers = get().servers.map(s =>\n      s.id === id ? { ...s, ...updates } : s\n    )\n    await store.set('mcp.servers', servers)\n    await store.save()\n    set({ servers })\n  },\n  \n  deleteServer: async (id: string) => {\n    const store = await Store.load('store.json')\n    const servers = get().servers.filter(s => s.id !== id)\n    const selectedServerIds = get().selectedServerIds.filter(sid => sid !== id)\n    await store.set('mcp.servers', servers)\n    await store.set('mcp.selectedServerIds', selectedServerIds)\n    await store.save()\n    \n    // 同时清理状态和选中\n    const serverStates = new Map(get().serverStates)\n    serverStates.delete(id)\n    \n    set({ servers, serverStates, selectedServerIds })\n  },\n  \n  toggleServerEnabled: async (id: string) => {\n    const store = await Store.load('store.json')\n    const servers = get().servers.map(s =>\n      s.id === id ? { ...s, enabled: !s.enabled } : s\n    )\n    await store.set('mcp.servers', servers)\n    await store.save()\n    set({ servers })\n  },\n  \n  setServerState: (id: string, state: MCPServerState) => {\n    const serverStates = new Map(get().serverStates)\n    serverStates.set(id, state)\n    set({ serverStates })\n  },\n  \n  getServerState: (id: string) => {\n    return get().serverStates.get(id)\n  },\n  \n  setSelectedServers: async (ids: string[]) => {\n    const store = await Store.load('store.json')\n    await store.set('mcp.selectedServerIds', ids)\n    await store.save()\n    set({ selectedServerIds: ids })\n  },\n  \n  toggleServerSelection: async (id: string) => {\n    const selectedServerIds = get().selectedServerIds\n    const newSelected = selectedServerIds.includes(id)\n      ? selectedServerIds.filter(sid => sid !== id)\n      : [...selectedServerIds, id]\n    \n    const store = await Store.load('store.json')\n    await store.set('mcp.selectedServerIds', newSelected)\n    await store.save()\n    set({ selectedServerIds: newSelected })\n  },\n  \n  clearSelectedServers: async () => {\n    const store = await Store.load('store.json')\n    await store.set('mcp.selectedServerIds', [])\n    await store.save()\n    set({ selectedServerIds: [] })\n  },\n  \n  loadMcpConfig: async () => {\n    try {\n      const store = await Store.load('store.json')\n      const servers = await store.get<MCPServerConfig[]>('mcp.servers')\n      const selectedServerIds = await store.get<string[]>('mcp.selectedServerIds')\n\n      set({\n        servers: servers ?? [],\n        selectedServerIds: selectedServerIds ?? [],\n      })\n    } catch (error) {\n      console.error('Failed to load MCP config:', error)\n    }\n  },\n\n  initMcpData: async () => {\n    // 如果已经初始化过，只加载配置不重新连接\n    if (get().initialized) {\n      await get().loadMcpConfig()\n      return\n    }\n\n    try {\n      const store = await Store.load('store.json')\n      const servers = await store.get<MCPServerConfig[]>('mcp.servers')\n      const selectedServerIds = await store.get<string[]>('mcp.selectedServerIds')\n\n      set({\n        servers: servers ?? [],\n        selectedServerIds: selectedServerIds ?? [],\n        initialized: true,\n      })\n    } catch (error) {\n      console.error('Failed to initialize MCP data:', error)\n    }\n  },\n}))\n"
  },
  {
    "path": "src/stores/memories.ts",
    "content": "import { create } from 'zustand'\nimport { Memory, getAllMemories, deleteMemory as deleteMemoryDb, upsertMemory, getMemoryStats } from '@/db/memories'\nimport { fetchEmbedding } from '@/lib/ai/embedding'\n\ninterface MemoriesState {\n  memories: Memory[]\n  loading: boolean\n  stats: {\n    total: number\n    preferences: number\n    memories: number\n    totalAccessCount: number\n  } | null\n\n  // Actions\n  loadMemories: () => Promise<void>\n  loadStats: () => Promise<void>\n  addMemory: (content: string, category?: 'preference' | 'memory') => Promise<{ id: string; replaced: boolean }>\n  deleteMemory: (id: string) => Promise<void>\n  clearAllMemories: () => Promise<void>\n}\n\nconst useMemoriesStore = create<MemoriesState>((set, get) => ({\n  memories: [],\n  loading: false,\n  stats: null,\n\n  loadMemories: async () => {\n    set({ loading: true })\n    try {\n      const memories = await getAllMemories()\n      set({ memories, loading: false })\n    } catch (error) {\n      console.error('Failed to load memories:', error)\n      set({ loading: false })\n    }\n  },\n\n  loadStats: async () => {\n    try {\n      const stats = await getMemoryStats()\n      set({ stats })\n    } catch (error) {\n      console.error('Failed to load memory stats:', error)\n    }\n  },\n\n  addMemory: async (content, category) => {\n    const embedding = await fetchEmbedding(content)\n    if (!embedding) {\n      throw new Error('无法生成向量嵌入，请检查嵌入模型配置')\n    }\n\n    const result = await upsertMemory({\n      content,\n      embedding: JSON.stringify(embedding),\n      category,\n    })\n\n    // Reload memories and stats\n    await get().loadMemories()\n    await get().loadStats()\n\n    return result\n  },\n\n  deleteMemory: async (id) => {\n    await deleteMemoryDb(id)\n    await get().loadMemories()\n    await get().loadStats()\n  },\n\n  clearAllMemories: async () => {\n    const { clearAllMemories: clearDb } = await import('@/db/memories')\n    await clearDb()\n    await get().loadMemories()\n    await get().loadStats()\n  },\n}))\n\nexport default useMemoriesStore\n"
  },
  {
    "path": "src/stores/prompt.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\n\nexport interface Prompt {\n  id: string\n  title: string\n  content: string\n  isDefault?: boolean\n}\n\ninterface PromptState {\n  promptList: Prompt[]\n  currentPrompt: Prompt | null\n  \n  initPromptData: () => Promise<void>\n  setPromptList: (promptList: Prompt[]) => Promise<void>\n  addPrompt: (prompt: Omit<Prompt, 'id'>) => Promise<void>\n  updatePrompt: (prompt: Prompt) => Promise<void>\n  deletePrompt: (id: string) => Promise<void>\n  setCurrentPrompt: (prompt: Prompt | null) => Promise<void>\n}\n\nconst usePromptStore = create<PromptState>((set, get) => ({\n  promptList: [\n    {\n      id: '0',\n      title: '写作助手',\n      content: '请你扮演一个笔记软件的智能助手，可以参考记录内容，使用 markdown 语法，回答用户的问题。',\n      isDefault: true\n    }\n  ],\n  currentPrompt: null,\n  \n  initPromptData: async () => {\n    const store = await Store.load('store.json');\n    const promptList = await store.get<Prompt[]>('promptList');\n    if (promptList) {\n      set({ promptList });\n    } else {\n      // 如果不存在，设置默认\n      const defaultPromptList = get().promptList;\n      await store.set('promptList', defaultPromptList);\n    }\n    \n    // 设置当前使用的prompt\n    const currentPromptId = await store.get<string>('currentPromptId');\n    if (currentPromptId) {\n      const prompt = get().promptList.find(item => item.id === currentPromptId);\n      if (prompt) {\n        set({ currentPrompt: prompt });\n      }\n    } else {\n      // 默认使用第一个prompt\n      const defaultPrompt = get().promptList[0];\n      set({ currentPrompt: defaultPrompt });\n      await store.set('currentPromptId', defaultPrompt.id);\n    }\n  },\n  \n  setPromptList: async (promptList) => {\n    set({ promptList });\n    const store = await Store.load('store.json');\n    await store.set('promptList', promptList);\n  },\n  \n  addPrompt: async (promptData) => {\n    const prompt: Prompt = {\n      id: Date.now().toString(),\n      ...promptData\n    };\n    \n    const promptList = [...get().promptList, prompt];\n    await get().setPromptList(promptList);\n  },\n  \n  updatePrompt: async (updatedPrompt) => {\n    const promptList = get().promptList.map(prompt => \n      prompt.id === updatedPrompt.id ? updatedPrompt : prompt\n    );\n    \n    await get().setPromptList(promptList);\n    \n    // 如果更新的是当前选中的prompt，同时更新currentPrompt\n    const currentPrompt = get().currentPrompt;\n    if (currentPrompt && currentPrompt.id === updatedPrompt.id) {\n      set({ currentPrompt: updatedPrompt });\n    }\n  },\n  \n  deletePrompt: async (id) => {\n    // 不允许删除默认prompt\n    const promptToDelete = get().promptList.find(prompt => prompt.id === id);\n    if (promptToDelete?.isDefault) return;\n    \n    const promptList = get().promptList.filter(prompt => prompt.id !== id);\n    await get().setPromptList(promptList);\n    \n    // 如果删除的是当前选中的prompt，将当前prompt设置为默认prompt\n    const currentPrompt = get().currentPrompt;\n    if (currentPrompt && currentPrompt.id === id) {\n      const defaultPrompt = get().promptList.find(prompt => prompt.isDefault);\n      if (defaultPrompt) {\n        await get().setCurrentPrompt(defaultPrompt);\n      }\n    }\n  },\n  \n  setCurrentPrompt: async (prompt) => {\n    set({ currentPrompt: prompt });\n    if (prompt) {\n      const store = await Store.load('store.json');\n      await store.set('currentPromptId', prompt.id);\n    }\n  }\n}));\n\nexport default usePromptStore;\n"
  },
  {
    "path": "src/stores/ragSettings.ts",
    "content": "import { create } from 'zustand';\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { toast } from '@/hooks/use-toast';\n\n// RAG 设置参数接口\nexport interface RagSettings {\n  // 文本分块的最大字符数\n  chunkSize: number;\n  // 分块之间的重叠字符数\n  chunkOverlap: number;\n  // 检索返回的相关文档数量\n  resultCount: number;\n  // 文档相似度阈值 (0.0-1.0)\n  similarityThreshold: number;\n}\n\n// 默认参数值\nexport const DEFAULT_RAG_SETTINGS: RagSettings = {\n  chunkSize: 1000,\n  chunkOverlap: 200,\n  resultCount: 5,\n  similarityThreshold: 0.7\n};\n\n// RAG 设置状态接口\ninterface RagSettingsState extends RagSettings {\n  // 初始化设置\n  initSettings: () => Promise<void>;\n  // 更新单个设置项\n  updateSetting: <K extends keyof RagSettings>(key: K, value: RagSettings[K]) => Promise<void>;\n  // 重置所有设置为默认值\n  resetToDefaults: () => Promise<void>;\n}\n\n// 创建状态存储\nconst useRagSettingsStore = create<RagSettingsState>((set) => ({\n  ...DEFAULT_RAG_SETTINGS,\n\n  // 初始化设置\n  initSettings: async () => {\n    try {\n      const store = await Store.load('store.json');\n      \n      // 从存储中读取各个设置项，如果不存在则使用默认值\n      const chunkSize = await store.get<number>('ragChunkSize') || DEFAULT_RAG_SETTINGS.chunkSize;\n      const chunkOverlap = await store.get<number>('ragChunkOverlap') || DEFAULT_RAG_SETTINGS.chunkOverlap;\n      const resultCount = await store.get<number>('ragResultCount') || DEFAULT_RAG_SETTINGS.resultCount;\n      const similarityThreshold = await store.get<number>('ragSimilarityThreshold') || DEFAULT_RAG_SETTINGS.similarityThreshold;\n      \n      set({\n        chunkSize,\n        chunkOverlap,\n        resultCount,\n        similarityThreshold\n      });\n    } catch (error) {\n      console.error('初始化 RAG 设置失败:', error);\n    }\n  },\n\n  // 更新单个设置项\n  updateSetting: async <K extends keyof RagSettings>(key: K, value: RagSettings[K]) => {\n    try {\n      // 更新本地状态\n      set({ [key]: value } as Pick<RagSettings, K>);\n      \n      // 保存到存储\n      const store = await Store.load('store.json');\n      await store.set(`rag${key.charAt(0).toUpperCase() + key.slice(1)}`, value);\n    } catch (error) {\n      console.error(`更新 RAG 设置 ${key} 失败:`, error);\n    }\n  },\n\n  // 重置所有设置为默认值\n  resetToDefaults: async () => {\n    try {\n      // 更新本地状态\n      set(DEFAULT_RAG_SETTINGS);\n      \n      // 保存到存储\n      const store = await Store.load('store.json');\n      await store.set('ragChunkSize', DEFAULT_RAG_SETTINGS.chunkSize);\n      await store.set('ragChunkOverlap', DEFAULT_RAG_SETTINGS.chunkOverlap);\n      await store.set('ragResultCount', DEFAULT_RAG_SETTINGS.resultCount);\n      await store.set('ragSimilarityThreshold', DEFAULT_RAG_SETTINGS.similarityThreshold);\n    } catch (error) {\n      toast({\n        title: '重置 RAG 设置失败',\n        description: error as string,\n        variant: 'destructive',\n      });\n    }\n  }\n}));\n\nexport default useRagSettingsStore;\n"
  },
  {
    "path": "src/stores/recording.ts",
    "content": "import { create } from 'zustand'\n\ninterface RecordingState {\n  // 录音状态\n  isRecording: boolean\n  isPaused: boolean\n  recordingDuration: number // 录音时长（秒）\n\n  // 录音数据\n  audioChunks: Blob[]\n  mediaRecorder: MediaRecorder | null\n\n  // 计时器\n  timerId?: NodeJS.Timeout\n\n  // 控制方法\n  startRecording: () => Promise<void>\n  pauseRecording: () => void\n  resumeRecording: () => void\n  stopRecording: () => Promise<Blob | null>\n  cancelRecording: () => void\n  \n  // 内部方法\n  setRecordingDuration: (duration: number) => void\n  resetState: () => void\n}\n\nconst useRecordingStore = create<RecordingState>((set, get) => ({\n  isRecording: false,\n  isPaused: false,\n  recordingDuration: 0,\n  audioChunks: [],\n  mediaRecorder: null,\n\n  setRecordingDuration: (duration) => set({ recordingDuration: duration }),\n\n  startRecording: async () => {\n    try {\n      // 请求麦克风权限\n      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })\n      \n      // 优先尝试更兼容的格式\n      let mimeType = 'audio/webm'\n      const supportedTypes = [\n        'audio/wav',\n        'audio/mp4',\n        'audio/webm;codecs=opus',\n        'audio/ogg;codecs=opus',\n        'audio/webm'\n      ]\n      \n      for (const type of supportedTypes) {\n        if (MediaRecorder.isTypeSupported(type)) {\n          mimeType = type\n          break\n        }\n      }\n      \n      // 创建MediaRecorder实例\n      const mediaRecorder = new MediaRecorder(stream, { mimeType })\n      \n      const chunks: Blob[] = []\n      \n      mediaRecorder.ondataavailable = (event) => {\n        if (event.data.size > 0) {\n          chunks.push(event.data)\n        }\n      }\n      \n      mediaRecorder.onstop = () => {\n        // 停止所有音频轨道\n        stream.getTracks().forEach(track => track.stop())\n      }\n      \n      mediaRecorder.start()\n      \n      // 启动计时器，保存到 state\n      const timerId = setInterval(() => {\n        const state = get()\n        if (state.isRecording && !state.isPaused) {\n          set({ recordingDuration: state.recordingDuration + 1 })\n        } else {\n          // 暂停时清除计时器\n          clearInterval(state.timerId)\n          set({ timerId: undefined })\n        }\n      }, 1000)\n\n      set({\n        isRecording: true,\n        isPaused: false,\n        audioChunks: chunks,\n        mediaRecorder,\n        recordingDuration: 0,\n        timerId\n      })\n      \n    } catch (error) {\n      console.error('启动录音失败:', error)\n      \n      // 根据错误类型提供更具体的错误信息\n      if (error instanceof DOMException) {\n        if (error.name === 'NotAllowedError') {\n          throw new Error('麦克风权限被拒绝，请在系统设置中允许 NoteGen 访问麦克风')\n        } else if (error.name === 'NotFoundError') {\n          throw new Error('未检测到麦克风设备，请连接麦克风后重试')\n        } else if (error.name === 'NotReadableError') {\n          throw new Error('麦克风正在被其他应用使用，请关闭其他应用后重试')\n        }\n      }\n      \n      throw new Error('无法启动录音，请检查麦克风设备和权限设置')\n    }\n  },\n\n  pauseRecording: () => {\n    const { mediaRecorder, timerId } = get()\n    if (mediaRecorder && mediaRecorder.state === 'recording') {\n      mediaRecorder.pause()\n      // 暂停时清除计时器\n      if (timerId) {\n        clearInterval(timerId)\n      }\n      set({ isPaused: true, timerId: undefined })\n    }\n  },\n\n  resumeRecording: () => {\n    const { mediaRecorder } = get()\n    if (mediaRecorder && mediaRecorder.state === 'paused') {\n      mediaRecorder.resume()\n      set({ isPaused: false })\n    }\n  },\n\n  stopRecording: async (): Promise<Blob | null> => {\n    const { mediaRecorder, audioChunks, timerId } = get()\n\n    // 停止时清除计时器\n    if (timerId) {\n      clearInterval(timerId)\n    }\n\n    if (!mediaRecorder) {\n      return null\n    }\n    \n    return new Promise((resolve) => {\n      mediaRecorder.onstop = () => {\n        const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })\n        get().resetState()\n        resolve(audioBlob)\n      }\n      \n      mediaRecorder.stop()\n    })\n  },\n\n  cancelRecording: () => {\n    const { mediaRecorder } = get()\n    \n    if (mediaRecorder && mediaRecorder.state !== 'inactive') {\n      mediaRecorder.stop()\n    }\n    \n    get().resetState()\n  },\n\n  resetState: () => {\n    const { timerId } = get()\n    // 重置时清除计时器\n    if (timerId) {\n      clearInterval(timerId)\n    }\n    set({\n      isRecording: false,\n      isPaused: false,\n      recordingDuration: 0,\n      audioChunks: [],\n      mediaRecorder: null,\n      timerId: undefined\n    })\n  }\n}))\n\nexport default useRecordingStore\n"
  },
  {
    "path": "src/stores/setting.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\nimport { getVersion } from '@tauri-apps/api/app'\nimport { AiConfig } from '@/app/core/setting/config'\nimport { GitlabInstanceType } from '@/lib/sync/gitlab.types'\nimport { GiteaInstanceType } from '@/lib/sync/gitea.types'\nimport { noteGenDefaultModels, noteGenModelKeys } from '@/app/model-config'\nimport { fetch } from '@tauri-apps/plugin-http'\nimport { CustomThemeColors } from '@/types/theme'\nimport { applyThemeColors, removeThemeColors } from '@/lib/theme-utils'\nimport { normalizeSpeechMode } from '@/lib/speech/preferences'\nimport type { SpeechMode } from '@/lib/speech/types'\n\nexport enum GenTemplateRange {\n  All = 'all',\n  Today = 'today',\n  Week = 'week',\n  Month = 'month',\n  ThreeMonth = 'threeMonth',\n  Year = 'year',\n}\n\nexport interface GenTemplate {\n  id: string\n  title: string\n  status: boolean\n  content: string\n  range: GenTemplateRange\n}\n\ninterface SettingState {\n  initSettingData: () => Promise<void>\n\n  version: string\n  setVersion: () => Promise<void>\n\n  autoUpdate: boolean\n  setAutoUpdate: (autoUpdate: boolean) => void\n\n  language: string\n  setLanguage: (language: string) => void\n\n  // setting - ai - 当前选择的模型 key\n  currentAi: string\n  setCurrentAi: (currentAi: string) => void\n\n  aiModelList: AiConfig[]\n  setAiModelList: (aiModelList: AiConfig[]) => void\n\n  primaryModel: string\n  setPrimaryModel: (primaryModel: string) => void\n\n  placeholderModel: string\n  setPlaceholderModel: (placeholderModel: string) => Promise<void>\n\n  completionModel: string\n  setCompletionModel: (completionModel: string) => Promise<void>\n\n  markDescModel: string\n  setMarkDescModel: (markDescModel: string) => Promise<void>\n\n  commitModel: string\n  setCommitModel: (commitModel: string) => Promise<void>\n\n  embeddingModel: string\n  setEmbeddingModel: (embeddingModel: string) => Promise<void>\n\n  rerankingModel: string\n  setRerankingModel: (rerankingModel: string) => Promise<void>\n\n  imageMethodModel: string\n  setImageMethodModel: (imageMethodModel: string) => Promise<void>\n\n  audioModel: string\n  setAudioModel: (audioModel: string) => Promise<void>\n\n  sttModel: string\n  setSttModel: (sttModel: string) => Promise<void>\n\n  textToSpeechMode: SpeechMode\n  setTextToSpeechMode: (mode: SpeechMode) => Promise<void>\n\n  speechToTextMode: SpeechMode\n  setSpeechToTextMode: (mode: SpeechMode) => Promise<void>\n\n  condenseModel: string\n  setCondenseModel: (condenseModel: string) => Promise<void>\n\n  inspirationModel: string\n  setInspirationModel: (inspirationModel: string) => Promise<void>\n\n  templateList: GenTemplate[]\n  setTemplateList: (templateList: GenTemplate[]) => Promise<void>\n\n  darkMode: string\n  setDarkMode: (darkMode: string) => void\n\n  previewTheme: string\n  setPreviewTheme: (previewTheme: string) => void\n\n  codeTheme: string\n  setCodeTheme: (codeTheme: string) => void\n\n  tesseractList: string\n  setTesseractList: (tesseractList: string) => void\n\n  // Github 相关设置\n  githubUsername: string\n  setGithubUsername: (githubUsername: string) => Promise<void>\n\n  accessToken: string\n  setAccessToken: (accessToken: string) => void\n\n  jsdelivr: boolean\n  setJsdelivr: (jsdelivr: boolean) => void\n\n  useImageRepo: boolean\n  setUseImageRepo: (useImageRepo: boolean) => Promise<void>\n\n  autoSync: string\n  setAutoSync: (autoSync: string) => Promise<void>\n\n  // 自动拉取相关设置\n  autoPullOnOpen: boolean\n  setAutoPullOnOpen: (autoPullOnOpen: boolean) => Promise<void>\n\n  autoPullOnSwitch: boolean\n  setAutoPullOnSwitch: (autoPullOnSwitch: boolean) => Promise<void>\n\n  // Gitee 相关设置\n  giteeAccessToken: string\n  setGiteeAccessToken: (giteeAccessToken: string) => void\n\n  giteeAutoSync: string\n  setGiteeAutoSync: (giteeAutoSync: string) => Promise<void>\n\n  // Gitlab 相关设置\n  gitlabInstanceType: GitlabInstanceType\n  setGitlabInstanceType: (instanceType: GitlabInstanceType) => Promise<void>\n\n  gitlabCustomUrl: string\n  setGitlabCustomUrl: (customUrl: string) => Promise<void>\n\n  gitlabAccessToken: string\n  setGitlabAccessToken: (gitlabAccessToken: string) => void\n\n  gitlabAutoSync: string\n  setGitlabAutoSync: (gitlabAutoSync: string) => Promise<void>\n\n  gitlabUsername: string\n  setGitlabUsername: (gitlabUsername: string) => Promise<void>\n\n  // Gitea 相关设置\n  giteaInstanceType: GiteaInstanceType\n  setGiteaInstanceType: (instanceType: GiteaInstanceType) => Promise<void>\n\n  giteaCustomUrl: string\n  setGiteaCustomUrl: (customUrl: string) => Promise<void>\n\n  giteaAccessToken: string\n  setGiteaAccessToken: (giteaAccessToken: string) => void\n\n  giteaAutoSync: string\n  setGiteaAutoSync: (giteaAutoSync: string) => Promise<void>\n\n  giteaUsername: string\n  setGiteaUsername: (giteaUsername: string) => Promise<void>\n\n  // 主要备份方式设置\n  primaryBackupMethod: 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n  setPrimaryBackupMethod: (method: 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav') => Promise<void>\n\n  lastSettingPage: string\n  setLastSettingPage: (page: string) => Promise<void>\n\n  workspacePath: string\n  setWorkspacePath: (path: string) => Promise<void>\n\n  // 工作区历史路径\n  workspaceHistory: string[]\n  addWorkspaceHistory: (path: string) => Promise<void>\n  removeWorkspaceHistory: (path: string) => Promise<void>\n  clearWorkspaceHistory: () => Promise<void>\n\n  assetsPath: string\n  setAssetsPath: (path: string) => Promise<void>\n\n  // 图床设置\n  githubImageAccessToken: string\n  setGithubImageAccessToken: (githubImageAccessToken: string) => Promise<void>\n\n  // 自定义仓库名称设置\n  githubCustomSyncRepo: string\n  setGithubCustomSyncRepo: (repo: string) => Promise<void>\n\n  giteeCustomSyncRepo: string\n  setGiteeCustomSyncRepo: (repo: string) => Promise<void>\n\n  gitlabCustomSyncRepo: string\n  setGitlabCustomSyncRepo: (repo: string) => Promise<void>\n\n  giteaCustomSyncRepo: string\n  setGiteaCustomSyncRepo: (repo: string) => Promise<void>\n\n  githubCustomImageRepo: string\n  setGithubCustomImageRepo: (repo: string) => Promise<void>\n\n  // 图片识别设置\n  enableImageRecognition: boolean\n  setEnableImageRecognition: (enable: boolean) => Promise<void>\n  primaryImageMethod: 'ocr' | 'vlm'\n  setPrimaryImageMethod: (method: 'ocr' | 'vlm') => Promise<void>\n\n  // 界面缩放设置\n  uiScale: number\n  setUiScale: (scale: number) => Promise<void>\n\n  // 正文文字大小缩放设置\n  contentTextScale: number\n  setContentTextScale: (scale: number) => Promise<void>\n\n  // 文件管理器文字大小设置\n  fileManagerTextSize: string\n  setFileManagerTextSize: (size: string) => Promise<void>\n\n  // 记录文字大小设置\n  recordTextSize: string\n  setRecordTextSize: (size: string) => Promise<void>\n\n  // 自定义主题颜色设置\n  customThemeColors: CustomThemeColors\n  setCustomThemeColors: (colors: CustomThemeColors) => Promise<void>\n  resetCustomThemeColors: () => Promise<void>\n\n  // 聊天工具栏配置 - PC 端\n  chatToolbarConfigPc: ChatToolbarItem[]\n  setChatToolbarConfigPc: (config: ChatToolbarItem[]) => Promise<void>\n\n  // 聊天工具栏配置 - 移动端\n  chatToolbarConfigMobile: ChatToolbarItem[]\n  setChatToolbarConfigMobile: (config: ChatToolbarItem[]) => Promise<void>\n\n  // 记录工具栏配置\n  recordToolbarConfig: RecordToolbarItem[]\n  setRecordToolbarConfig: (config: RecordToolbarItem[]) => Promise<void>\n\n  // 编辑器撤销/重做按钮显示设置\n  showEditorUndoRedo: boolean\n  setShowEditorUndoRedo: (show: boolean) => Promise<void>\n\n  // 摘要设置\n  enableCondense: boolean\n  setEnableCondense: (enabled: boolean) => Promise<void>\n  keepLatestCount: number\n  setKeepLatestCount: (count: number) => Promise<void>\n  condenseMaxLength: number\n  setCondenseMaxLength: (length: number) => Promise<void>\n}\n\nexport interface ChatToolbarItem {\n  id: string\n  enabled: boolean\n  order: number\n}\n\nexport interface RecordToolbarItem {\n  id: string\n  enabled: boolean\n  order: number\n}\n\n\nconst useSettingStore = create<SettingState>((set, get) => ({\n  initSettingData: async () => {\n    const store = await Store.load('store.json');\n    await get().setVersion()\n\n    // 初始化图床配置\n    const savedUseImageRepo = await store.get<boolean>('useImageRepo')\n    if (savedUseImageRepo !== undefined && savedUseImageRepo !== null) {\n      set({ useImageRepo: savedUseImageRepo })\n    }\n\n    // 初始化默认的NoteGen模型配置\n    const existingAiModelList = (await store.get('aiModelList') as AiConfig[]) || []\n    const hasNoteGenModels = existingAiModelList.some(config => \n      config.key === 'note-gen-free' || \n      noteGenModelKeys.includes(config.key) ||\n      config.models?.some(model => noteGenModelKeys.includes(model.id))\n    )\n    \n    let finalAiModelList = existingAiModelList\n    if (!hasNoteGenModels) {\n      finalAiModelList = [...existingAiModelList, ...noteGenDefaultModels]\n      await store.set('aiModelList', finalAiModelList)\n      set({ aiModelList: finalAiModelList })\n    }\n\n    // 检查是否设置了主要模型，如果没有且存在note-gen-chat，则设置为主要模型\n    const currentPrimaryModel = await store.get('primaryModel') as string\n    const hasNoteGenChat = finalAiModelList.some(config => \n      config.models?.some(model => model.id === 'note-gen-chat') || config.key === 'note-gen-chat'\n    )\n    \n    if (!currentPrimaryModel && hasNoteGenChat) {\n      const noteGenFreeConfig = finalAiModelList.find(config => config.key === 'note-gen-free')\n      if (noteGenFreeConfig?.models?.some(model => model.id === 'note-gen-chat')) {\n        await store.set('primaryModel', 'note-gen-chat')\n        set({ primaryModel: 'note-gen-chat' })\n      } else {\n        await store.set('primaryModel', 'note-gen-chat')\n        set({ primaryModel: 'note-gen-chat' })\n      }\n    }\n\n    // 检查是否设置了嵌入模型，如果没有且存在note-gen-embedding，则设置为默认嵌入模型\n    const currentEmbeddingModel = await store.get('embeddingModel') as string\n    const hasNoteGenEmbedding = finalAiModelList.some(config => \n      config.models?.some(model => model.id === 'note-gen-embedding') || config.key === 'note-gen-embedding'\n    )\n    \n    if (!currentEmbeddingModel && hasNoteGenEmbedding) {\n      const noteGenFreeConfig = finalAiModelList.find(config => config.key === 'note-gen-free')\n      if (noteGenFreeConfig?.models?.some(model => model.id === 'note-gen-embedding')) {\n        await store.set('embeddingModel', 'note-gen-embedding')\n        set({ embeddingModel: 'note-gen-embedding' })\n      } else {\n        await store.set('embeddingModel', 'note-gen-embedding')\n        set({ embeddingModel: 'note-gen-embedding' })\n      }\n    }\n\n    // 检查是否设置了视觉语言模型，如果没有且存在note-gen-vlm，则设置为默认视觉语言模型\n    const currentImageMethodModel = await store.get('imageMethodModel') as string\n    const hasNoteGenVlm = finalAiModelList.some(config => \n      config.models?.some(model => model.id === 'note-gen-vlm') || config.key === 'note-gen-vlm'\n    )\n    \n    if (!currentImageMethodModel && hasNoteGenVlm) {\n      const noteGenFreeConfig = finalAiModelList.find(config => config.key === 'note-gen-free')\n      if (noteGenFreeConfig?.models?.some(model => model.id === 'note-gen-vlm')) {\n        await store.set('imageMethodModel', 'note-gen-vlm')\n        set({ imageMethodModel: 'note-gen-vlm' })\n      } else {\n        await store.set('imageMethodModel', 'note-gen-vlm')\n        set({ imageMethodModel: 'note-gen-vlm' })\n      }\n    }\n\n    // 检查是否设置了TTS模型，如果没有且存在note-gen-tts，则设置为默认TTS模型\n    const currentAudioModel = await store.get('audioModel') as string\n    const hasNoteGenTTS = finalAiModelList.some(config => \n      config.models?.some(model => model.modelType === 'tts') || config.modelType === 'tts'\n    )\n    \n    if (!currentAudioModel && hasNoteGenTTS) {\n      // 查找第一个可用的TTS模型\n      for (const config of finalAiModelList) {\n        if (config.models && config.models.length > 0) {\n          const ttsModel = config.models.find(model => model.modelType === 'tts')\n          if (ttsModel) {\n            await store.set('audioModel', `${config.key}-${ttsModel.id}`)\n            set({ audioModel: `${config.key}-${ttsModel.id}` })\n            break\n          }\n        } else if (config.modelType === 'tts') {\n          await store.set('audioModel', config.key)\n          set({ audioModel: config.key })\n          break\n        }\n      }\n    }\n\n    // 检查是否设置了STT模型，如果没有且存在note-gen-stt，则设置为默认STT模型\n    const currentSttModel = await store.get('sttModel') as string\n    const hasNoteGenSTT = finalAiModelList.some(config => \n      config.models?.some(model => model.modelType === 'stt') || config.modelType === 'stt'\n    )\n    \n    if (!currentSttModel && hasNoteGenSTT) {\n      // 查找第一个可用的STT模型\n      for (const config of finalAiModelList) {\n        if (config.models && config.models.length > 0) {\n          const sttModel = config.models.find(model => model.modelType === 'stt')\n          if (sttModel) {\n            await store.set('sttModel', `${config.key}-${sttModel.id}`)\n            set({ sttModel: `${config.key}-${sttModel.id}` })\n            break\n          }\n        } else if (config.modelType === 'stt') {\n          await store.set('sttModel', config.key)\n          set({ sttModel: config.key })\n          break\n        }\n      }\n    }\n\n    const currentTextToSpeechMode = await store.get('textToSpeechMode')\n    set({ textToSpeechMode: normalizeSpeechMode(currentTextToSpeechMode) })\n\n    const currentSpeechToTextMode = await store.get('speechToTextMode')\n    set({ speechToTextMode: normalizeSpeechMode(currentSpeechToTextMode) })\n\n    // 检查并初始化其他模型类型\n    const modelTypes = [\n      { storeKey: 'completionModel', modelType: 'chat' },\n      { storeKey: 'markDescModel', modelType: 'chat' },\n      { storeKey: 'commitModel', modelType: 'chat' },\n      { storeKey: 'condenseModel', modelType: 'chat' },\n      { storeKey: 'inspirationModel', modelType: 'chat' }\n    ]\n\n    for (const { storeKey, modelType } of modelTypes) {\n      const currentModel = await store.get(storeKey) as string\n      if (!currentModel) {\n        // 查找第一个可用的聊天模型作为默认值\n        const noteGenFreeConfig = finalAiModelList.find(config => config.key === 'note-gen-free')\n        if (noteGenFreeConfig?.models?.some(model => model.id === 'note-gen-chat' && model.modelType === modelType)) {\n          await store.set(storeKey, 'note-gen-chat')\n          set({ [storeKey]: 'note-gen-chat' })\n        } else {\n          // 查找其他可用的聊天模型\n          for (const config of finalAiModelList) {\n            if (config.models && config.models.length > 0) {\n              const chatModel = config.models.find(model => model.modelType === modelType)\n              if (chatModel) {\n                await store.set(storeKey, `${config.key}-${chatModel.id}`)\n                set({ [storeKey]: `${config.key}-${chatModel.id}` })\n                break\n              }\n            } else if (config.modelType === modelType || !config.modelType) {\n              await store.set(storeKey, config.key)\n              set({ [storeKey]: config.key })\n              break\n            }\n          }\n        }\n      }\n    }\n\n    // 获取 NoteGen 限时免费模型\n    // 如果服务不可用,静默失败,不影响用户使用自己的模型\n    try {\n      const apiKey = noteGenDefaultModels[0].apiKey\n      const headers = {\n        'Content-Type': 'application/json',\n        'Authorization': `Bearer ${apiKey}`\n      }\n      const res = await fetch('https://api.notegen.top/v1/models', {\n        method: 'GET',\n        headers\n      })\n\n      // 检查响应状态\n      if (!res.ok) {\n        throw new Error(`API responded with status: ${res.status}`)\n      }\n\n      const resModels = await res.json()\n\n      if (resModels.data && resModels.data.length > 0) {\n        // 移除旧的 NoteGen Limited 配置\n        finalAiModelList = finalAiModelList.filter(model => \n          model.title !== 'NoteGen Limited' && model.key !== 'note-gen-limited'\n        )\n        \n        // 过滤出不在默认模型中的限时免费模型\n        const limitedModels = resModels.data.filter((model: any) => {\n          // 检查是否在 noteGenDefaultModels 的 models 数组中\n          return !noteGenDefaultModels[0].models?.some(defaultModel => defaultModel.model === model.id)\n        })\n        \n        // 如果有限时免费模型,创建统一的 NoteGen Limited 配置\n        if (limitedModels.length > 0) {\n          const noteGenLimitedConfig = {\n            apiKey,\n            baseURL: \"https://api.notegen.top/v1\",\n            key: \"note-gen-limited\",\n            title: \"NoteGen Limited\",\n            models: limitedModels.map((model: any) => ({\n              id: `note-gen-limited-${model.id}`,\n              model: model.id,\n              modelType: \"chat\",\n              temperature: 0.7,\n              topP: 1,\n              enableStream: true\n            }))\n          }\n          \n          finalAiModelList.push(noteGenLimitedConfig)\n          await store.set('aiModelList', finalAiModelList)\n          set({ aiModelList: finalAiModelList })\n        }\n      }\n    } catch (error) {\n      // 静默处理错误,不影响应用初始化和用户使用自己的模型\n      console.debug('NoteGen API service unavailable, skipping limited models:', error)\n    }\n\n    Object.entries(get()).forEach(async ([key, value]) => {\n      const res = await store.get(key)\n\n      if (typeof value === 'function') return\n      if (res !== undefined && key !== 'version') {\n        if (key === 'templateList') {\n          set({ [key]: [] })\n          setTimeout(() => {\n            set({ [key]: res as GenTemplate[] })\n          }, 0);\n        } else if (key === 'aiModelList' && hasNoteGenModels) {\n          // 如果已经有NoteGen模型，使用存储的配置\n          set({ [key]: res as AiConfig[] })\n        } else if (key === 'recordToolbarConfig') {\n          // 确保包含所有工具，如果缺少新工具则自动添加\n          const storedConfig = res as RecordToolbarItem[]\n          const defaultConfig = value as RecordToolbarItem[]\n\n          // 检查是否有缺失的工具\n          const missingTools = defaultConfig.filter(\n            defaultItem => !storedConfig.some(stored => stored.id === defaultItem.id)\n          )\n\n          if (missingTools.length > 0) {\n            // 合并配置：保留用户的顺序和启用状态，添加新工具\n            const mergedConfig = [...storedConfig]\n            let maxOrder = Math.max(...storedConfig.map(item => item.order), 0)\n\n            missingTools.forEach(tool => {\n              mergedConfig.push({ ...tool, order: ++maxOrder })\n            })\n\n            await store.set(key, mergedConfig)\n            set({ [key]: mergedConfig })\n          } else {\n            set({ [key]: res as RecordToolbarItem[] })\n          }\n        } else if (key === 'chatToolbarConfigPc' || key === 'chatToolbarConfigMobile') {\n          // 确保聊天工具栏包含所有工具，如果缺少新工具则自动添加\n          const storedConfig = res as ChatToolbarItem[]\n          const defaultConfig = value as ChatToolbarItem[]\n\n          // 检查是否有缺失的工具\n          const missingTools = defaultConfig.filter(\n            defaultItem => !storedConfig.some(stored => stored.id === defaultItem.id)\n          )\n\n          if (missingTools.length > 0) {\n            // 合并配置：保留用户的顺序和启用状态，添加新工具\n            const mergedConfig = [...storedConfig]\n            let maxOrder = Math.max(...storedConfig.map(item => item.order), 0)\n\n            missingTools.forEach(tool => {\n              mergedConfig.push({ ...tool, order: ++maxOrder })\n            })\n\n            await store.set(key, mergedConfig)\n            set({ [key]: mergedConfig })\n          } else {\n            set({ [key]: res as ChatToolbarItem[] })\n          }\n        } else if (key !== 'aiModelList') {\n          set({ [key]: res })\n        }\n      } else {\n        await store.set(key, value)\n      }\n    })\n  },\n\n  version: '',\n  setVersion: async () => {\n    const version = await getVersion()\n    set({ version })\n  },\n\n  autoUpdate: true,\n  setAutoUpdate: (autoUpdate) => set({ autoUpdate }),\n\n  language: '简体中文',\n  setLanguage: (language) => set({ language }),\n\n  currentAi: '',\n  setCurrentAi: (currentAi) => set({ currentAi }),\n\n  aiModelList: [],\n  setAiModelList: (aiModelList) => set({ aiModelList }),\n\n  primaryModel: '',\n  setPrimaryModel: (primaryModel) => set({ primaryModel }),\n\n  placeholderModel: '',\n  setPlaceholderModel: async (placeholderModel) => {\n    const store = await Store.load('store.json');\n    await store.set('placeholderModel', placeholderModel)\n    set({ placeholderModel })\n  },\n\n  completionModel: '',\n  setCompletionModel: async (completionModel) => {\n    const store = await Store.load('store.json');\n    await store.set('completionModel', completionModel)\n    set({ completionModel })\n  },\n\n  markDescModel: '',\n  setMarkDescModel: async (markDescModel) => {\n    const store = await Store.load('store.json');\n    await store.set('markDescModel', markDescModel)\n    set({ markDescModel })\n  },\n\n  commitModel: '',\n  setCommitModel: async (commitModel) => {\n    const store = await Store.load('store.json');\n    await store.set('commitModel', commitModel)\n    set({ commitModel })\n  },\n\n  embeddingModel: '',\n  setEmbeddingModel: async (embeddingModel) => {\n    const store = await Store.load('store.json');\n    await store.set('embeddingModel', embeddingModel)\n    set({ embeddingModel })\n  },\n\n  rerankingModel: '',\n  setRerankingModel: async (rerankingModel) => {\n    const store = await Store.load('store.json');\n    await store.set('rerankingModel', rerankingModel)\n    set({ rerankingModel })\n  },\n\n  imageMethodModel: '',\n  setImageMethodModel: async (imageMethodModel) => {\n    const store = await Store.load('store.json');\n    await store.set('imageMethodModel', imageMethodModel)\n    set({ imageMethodModel })\n  },\n\n  audioModel: '',\n  setAudioModel: async (audioModel) => {\n    const store = await Store.load('store.json');\n    await store.set('audioModel', audioModel)\n    set({ audioModel })\n  },\n\n  sttModel: '',\n  setSttModel: async (sttModel) => {\n    const store = await Store.load('store.json');\n    await store.set('sttModel', sttModel)\n    set({ sttModel })\n  },\n\n  textToSpeechMode: 'auto',\n  setTextToSpeechMode: async (mode) => {\n    const normalizedMode = normalizeSpeechMode(mode)\n    const store = await Store.load('store.json')\n    await store.set('textToSpeechMode', normalizedMode)\n    set({ textToSpeechMode: normalizedMode })\n  },\n\n  speechToTextMode: 'auto',\n  setSpeechToTextMode: async (mode) => {\n    const normalizedMode = normalizeSpeechMode(mode)\n    const store = await Store.load('store.json')\n    await store.set('speechToTextMode', normalizedMode)\n    set({ speechToTextMode: normalizedMode })\n  },\n\n  condenseModel: '',\n  setCondenseModel: async (condenseModel) => {\n    const store = await Store.load('store.json');\n    await store.set('condenseModel', condenseModel)\n    set({ condenseModel })\n  },\n\n  inspirationModel: '',\n  setInspirationModel: async (inspirationModel) => {\n    const store = await Store.load('store.json');\n    await store.set('inspirationModel', inspirationModel)\n    set({ inspirationModel })\n  },\n\n  templateList: [\n    {\n      id: '0',\n      title: '笔记',\n      content: `整理成一篇详细完整的笔记。\n满足以下格式要求：\n- 如果是代码，必须完整保留，不要随意生成。\n- 文字复制的内容尽量不要修改，只处理格式化后的内容。`,\n      status: true,\n      range: GenTemplateRange.All\n    },\n    {\n      id: '1',\n      title: '周报',\n      content: '最近一周的记录整理成一篇周报，将每条记录形成一句总结，每条不超过50字。',\n      status: true,\n      range: GenTemplateRange.Week\n    }\n  ],\n  setTemplateList: async (templateList) => {\n    set({ templateList })\n    const store = await Store.load('store.json')\n    await store.set('templateList', templateList)\n  },\n\n  darkMode: 'system',\n  setDarkMode: (darkMode) => set({ darkMode }),\n\n  previewTheme: 'github',\n  setPreviewTheme: (previewTheme) => set({ previewTheme }),\n\n  codeTheme: 'github',\n  setCodeTheme: (codeTheme) => set({ codeTheme }),\n\n  tesseractList: 'eng,chi_sim',\n  setTesseractList: (tesseractList) => set({ tesseractList }),\n\n  githubUsername: '',\n  setGithubUsername: async (githubUsername) => {\n    set({ githubUsername })\n    const store = await Store.load('store.json');\n    store.set('githubUsername', githubUsername)\n  },\n\n  accessToken: '',\n  setAccessToken: async (accessToken) => {\n    const store = await Store.load('store.json');\n    const hasAccessToken = await store.get('accessToken') === accessToken\n    if (!hasAccessToken) {\n      await get().setGithubUsername('')\n    }\n    set({ accessToken })\n  },\n\n  jsdelivr: true,\n  setJsdelivr: async (jsdelivr: boolean) => {\n    set({ jsdelivr })\n    const store = await Store.load('store.json');\n    await store.set('jsdelivr', jsdelivr)\n  },\n\n  useImageRepo: false,\n  setUseImageRepo: async (useImageRepo: boolean) => {\n    set({ useImageRepo })\n    const store = await Store.load('store.json');\n    await store.set('useImageRepo', useImageRepo)\n  },\n\n  autoSync: 'disabled',\n  setAutoSync: async (autoSync: string) => {\n    set({ autoSync })\n    const store = await Store.load('store.json');\n    await store.set('autoSync', autoSync)\n  },\n\n  // 自动拉取相关设置 - 默认关闭\n  autoPullOnOpen: false,\n  setAutoPullOnOpen: async (autoPullOnOpen: boolean) => {\n    set({ autoPullOnOpen })\n    const store = await Store.load('store.json');\n    await store.set('autoPullOnOpen', autoPullOnOpen)\n\n    // 同步更新 sync-manager 的配置\n    try {\n      const { getSyncManager } = await import('@/lib/sync/sync-manager')\n      const manager = getSyncManager()\n      await manager.updateConfig({ autoPullOnOpen })\n    } catch {\n      // 静默处理\n    }\n  },\n\n  autoPullOnSwitch: false,\n  setAutoPullOnSwitch: async (autoPullOnSwitch: boolean) => {\n    set({ autoPullOnSwitch })\n    const store = await Store.load('store.json');\n    await store.set('autoPullOnSwitch', autoPullOnSwitch)\n\n    // 同步更新 sync-manager 的配置\n    try {\n      const { getSyncManager } = await import('@/lib/sync/sync-manager')\n      const manager = getSyncManager()\n      await manager.updateConfig({ autoPullOnSwitch })\n    } catch {\n      // 静默处理\n    }\n  },\n\n  lastSettingPage: 'ai',\n  setLastSettingPage: async (page: string) => {\n    set({ lastSettingPage: page })\n    const store = await Store.load('store.json');\n    await store.set('lastSettingPage', page)\n  },\n\n  workspacePath: '',\n  setWorkspacePath: async (path: string) => {\n    set({ workspacePath: path })\n    const store = await Store.load('store.json');\n    await store.set('workspacePath', path)\n    \n    // 如果路径不为空且不在历史记录中，则添加到历史记录\n    if (path && !get().workspaceHistory.includes(path)) {\n      await get().addWorkspaceHistory(path)\n    }\n  },\n\n  // 工作区历史路径管理\n  workspaceHistory: [],\n  addWorkspaceHistory: async (path: string) => {\n    const currentHistory = get().workspaceHistory\n    const newHistory = [path, ...currentHistory.filter(p => p !== path)].slice(0, 10) // 最多保存10个历史路径\n    set({ workspaceHistory: newHistory })\n    const store = await Store.load('store.json')\n    await store.set('workspaceHistory', newHistory)\n    await store.save()\n  },\n  removeWorkspaceHistory: async (path: string) => {\n    const newHistory = get().workspaceHistory.filter(p => p !== path)\n    set({ workspaceHistory: newHistory })\n    const store = await Store.load('store.json')\n    await store.set('workspaceHistory', newHistory)\n    await store.save()\n  },\n  clearWorkspaceHistory: async () => {\n    set({ workspaceHistory: [] })\n    const store = await Store.load('store.json')\n    await store.set('workspaceHistory', [])\n    await store.save()\n  },\n\n  // Gitee 相关设置\n  giteeAccessToken: '',\n  setGiteeAccessToken: async (giteeAccessToken: string) => {\n    set({ giteeAccessToken })\n    const store = await Store.load('store.json');\n    await store.set('giteeAccessToken', giteeAccessToken)\n  },\n\n  giteeAutoSync: 'disabled',\n  setGiteeAutoSync: async (giteeAutoSync: string) => {\n    set({ giteeAutoSync })\n    const store = await Store.load('store.json');\n    await store.set('giteeAutoSync', giteeAutoSync)\n  },\n\n  // Gitlab 相关设置\n  gitlabInstanceType: GitlabInstanceType.OFFICIAL,\n  setGitlabInstanceType: async (instanceType: GitlabInstanceType) => {\n    const store = await Store.load('store.json')\n    await store.set('gitlabInstanceType', instanceType)\n    await store.save()\n    set({ gitlabInstanceType: instanceType })\n  },\n\n  gitlabCustomUrl: '',\n  setGitlabCustomUrl: async (customUrl: string) => {\n    const store = await Store.load('store.json')\n    await store.set('gitlabCustomUrl', customUrl)\n    await store.save()\n    set({ gitlabCustomUrl: customUrl })\n  },\n\n  gitlabAccessToken: '',\n  setGitlabAccessToken: (gitlabAccessToken: string) => {\n    set({ gitlabAccessToken })\n  },\n\n  gitlabAutoSync: 'disabled',\n  setGitlabAutoSync: async (gitlabAutoSync: string) => {\n    const store = await Store.load('store.json')\n    await store.set('gitlabAutoSync', gitlabAutoSync)\n    await store.save()\n    set({ gitlabAutoSync })\n  },\n\n  gitlabUsername: '',\n  setGitlabUsername: async (gitlabUsername: string) => {\n    const store = await Store.load('store.json')\n    await store.set('gitlabUsername', gitlabUsername)\n    await store.save()\n    set({ gitlabUsername })\n  },\n\n  // Gitea 相关实现\n  giteaInstanceType: GiteaInstanceType.OFFICIAL,\n  setGiteaInstanceType: async (instanceType: GiteaInstanceType) => {\n    const store = await Store.load('store.json')\n    await store.set('giteaInstanceType', instanceType)\n    await store.save()\n    set({ giteaInstanceType: instanceType })\n  },\n\n  giteaCustomUrl: '',\n  setGiteaCustomUrl: async (customUrl: string) => {\n    const store = await Store.load('store.json')\n    await store.set('giteaCustomUrl', customUrl)\n    await store.save()\n    set({ giteaCustomUrl: customUrl })\n  },\n\n  giteaAccessToken: '',\n  setGiteaAccessToken: (giteaAccessToken: string) => {\n    set({ giteaAccessToken })\n  },\n\n  giteaAutoSync: 'disabled',\n  setGiteaAutoSync: async (giteaAutoSync: string) => {\n    set({ giteaAutoSync })\n    const store = await Store.load('store.json');\n    await store.set('giteaAutoSync', giteaAutoSync)\n    await store.save()\n  },\n\n  giteaUsername: '',\n  setGiteaUsername: async (giteaUsername: string) => {\n    const store = await Store.load('store.json')\n    await store.set('giteaUsername', giteaUsername)\n    await store.save()\n    set({ giteaUsername })\n  },\n\n  giteaCustomSyncRepo: '',\n  setGiteaCustomSyncRepo: async (repo: string) => {\n    set({ giteaCustomSyncRepo: repo })\n    const store = await Store.load('store.json');\n    await store.set('giteaCustomSyncRepo', repo)\n    await store.save()\n  },\n\n  // 默认使用 GitHub 作为主要备份方式\n  primaryBackupMethod: 'github',\n  setPrimaryBackupMethod: async (method: 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav') => {\n    const store = await Store.load('store.json')\n    await store.set('primaryBackupMethod', method)\n    await store.save()\n    set({ primaryBackupMethod: method })\n  },\n\n  assetsPath: 'assets',\n  setAssetsPath: async (path: string) => {\n    set({ assetsPath: path })\n    const store = await Store.load('store.json');\n    await store.set('assetsPath', path)\n    await store.save()\n  },\n\n  // 图床设置\n  githubImageAccessToken: '',\n  setGithubImageAccessToken: async (githubImageAccessToken: string) => {\n    set({ githubImageAccessToken })\n    const store = await Store.load('store.json');\n    await store.set('githubImageAccessToken', githubImageAccessToken)\n    await store.save()\n  },\n\n  // 图片识别设置\n  enableImageRecognition: true,\n  setEnableImageRecognition: async (enable: boolean) => {\n    set({ enableImageRecognition: enable })\n    const store = await Store.load('store.json');\n    await store.set('enableImageRecognition', enable)\n    await store.save()\n  },\n  primaryImageMethod: 'vlm',\n  setPrimaryImageMethod: async (method: 'ocr' | 'vlm') => {\n    set({ primaryImageMethod: method })\n    const store = await Store.load('store.json');\n    await store.set('primaryImageMethod', method)\n    await store.save()\n  },\n\n  // 界面缩放设置 (75%, 100%, 125%, 150%)\n  uiScale: 100,\n  setUiScale: async (scale: number) => {\n    set({ uiScale: scale })\n    const store = await Store.load('store.json');\n    await store.set('uiScale', scale)\n    await store.save()\n    \n    // 使用fontSize实现基于rem的缩放\n    document.documentElement.style.fontSize = `${scale}%`\n  },\n\n  // 正文文字大小缩放设置 (75%, 100%, 125%, 150%)\n  contentTextScale: 100,\n  setContentTextScale: async (scale: number) => {\n    set({ contentTextScale: scale })\n    const store = await Store.load('store.json');\n    await store.set('contentTextScale', scale)\n    await store.save()\n  },\n\n  // 文件管理器文字大小设置 (xs, sm, md, lg, xl)\n  fileManagerTextSize: 'sm',\n  setFileManagerTextSize: async (size: string) => {\n    set({ fileManagerTextSize: size })\n    const store = await Store.load('store.json');\n    await store.set('fileManagerTextSize', size)\n    await store.save()\n  },\n\n  // 记录文字大小设置 (xs, sm, md, lg, xl)\n  recordTextSize: 'sm',\n  setRecordTextSize: async (size: string) => {\n    set({ recordTextSize: size })\n    const store = await Store.load('store.json');\n    await store.set('recordTextSize', size)\n    await store.save()\n  },\n\n  // 自定义主题颜色设置\n  customThemeColors: {\n    light: {\n      background: null,\n      foreground: null,\n      card: null,\n      cardForeground: null,\n      primary: null,\n      primaryForeground: null,\n      secondary: null,\n      secondaryForeground: null,\n      third: null,\n      thirdForeground: null,\n      muted: null,\n      mutedForeground: null,\n      accent: null,\n      accentForeground: null,\n      border: null,\n      shadow: null,\n    },\n    dark: {\n      background: null,\n      foreground: null,\n      card: null,\n      cardForeground: null,\n      primary: null,\n      primaryForeground: null,\n      secondary: null,\n      secondaryForeground: null,\n      third: null,\n      thirdForeground: null,\n      muted: null,\n      mutedForeground: null,\n      accent: null,\n      accentForeground: null,\n      border: null,\n      shadow: null,\n    },\n  },\n  setCustomThemeColors: async (colors: CustomThemeColors) => {\n    set({ customThemeColors: colors })\n    const store = await Store.load('store.json');\n    await store.set('customThemeColors', colors)\n    await store.save()\n\n    // 应用主题颜色（同时应用亮色和暗色主题）\n    applyThemeColors(colors)\n  },\n  resetCustomThemeColors: async () => {\n    const defaultColors: CustomThemeColors = {\n      light: {\n        background: null,\n        foreground: null,\n        card: null,\n        cardForeground: null,\n        primary: null,\n        primaryForeground: null,\n        secondary: null,\n        secondaryForeground: null,\n        third: null,\n        thirdForeground: null,\n        muted: null,\n        mutedForeground: null,\n        accent: null,\n        accentForeground: null,\n        border: null,\n        shadow: null,\n      },\n      dark: {\n        background: null,\n        foreground: null,\n        card: null,\n        cardForeground: null,\n        primary: null,\n        primaryForeground: null,\n        secondary: null,\n        secondaryForeground: null,\n        third: null,\n        thirdForeground: null,\n        muted: null,\n        mutedForeground: null,\n        accent: null,\n        accentForeground: null,\n        border: null,\n        shadow: null,\n      },\n    }\n    set({ customThemeColors: defaultColors })\n    const store = await Store.load('store.json');\n    await store.set('customThemeColors', defaultColors)\n    await store.save()\n\n    // 清除自定义主题颜色\n    removeThemeColors()\n  },\n\n  // 自定义仓库名称设置\n  githubCustomSyncRepo: '',\n  setGithubCustomSyncRepo: async (repo: string) => {\n    set({ githubCustomSyncRepo: repo })\n    const store = await Store.load('store.json');\n    await store.set('githubCustomSyncRepo', repo)\n    await store.save()\n  },\n\n  giteeCustomSyncRepo: '',\n  setGiteeCustomSyncRepo: async (repo: string) => {\n    set({ giteeCustomSyncRepo: repo })\n    const store = await Store.load('store.json');\n    await store.set('giteeCustomSyncRepo', repo)\n    await store.save()\n  },\n\n  gitlabCustomSyncRepo: '',\n  setGitlabCustomSyncRepo: async (repo: string) => {\n    set({ gitlabCustomSyncRepo: repo })\n    const store = await Store.load('store.json');\n    await store.set('gitlabCustomSyncRepo', repo)\n    await store.save()\n  },\n\n  githubCustomImageRepo: '',\n  setGithubCustomImageRepo: async (repo: string) => {\n    set({ githubCustomImageRepo: repo })\n    const store = await Store.load('store.json');\n    await store.set('githubCustomImageRepo', repo)\n    await store.save()\n  },\n\n  // 聊天工具栏配置 - PC 端\n  chatToolbarConfigPc: [\n    // 底部工具栏（可排序）\n    { id: 'modelSelect', enabled: true, order: 0 },\n    { id: 'promptSelect', enabled: true, order: 1 },\n    { id: 'mcpButton', enabled: true, order: 2 },\n    { id: 'ragSwitch', enabled: true, order: 3 },\n    { id: 'clipboardMonitor', enabled: true, order: 4 },\n    // 顶部工具栏 - 右侧（不参与排序）\n    { id: 'newChat', enabled: true, order: 5 },\n  ],\n  setChatToolbarConfigPc: async (config: ChatToolbarItem[]) => {\n    set({ chatToolbarConfigPc: config })\n    const store = await Store.load('store.json');\n    await store.set('chatToolbarConfigPc', config)\n    await store.save()\n  },\n\n  // 聊天工具栏配置 - 移动端\n  chatToolbarConfigMobile: [\n    { id: 'modelSelect', enabled: true, order: 0 },\n    { id: 'promptSelect', enabled: true, order: 1 },\n    { id: 'mcpButton', enabled: true, order: 2 },\n    { id: 'ragSwitch', enabled: true, order: 3 },\n    { id: 'clipboardMonitor', enabled: true, order: 4 },\n    { id: 'newChat', enabled: true, order: 5 },\n  ],\n  setChatToolbarConfigMobile: async (config: ChatToolbarItem[]) => {\n    set({ chatToolbarConfigMobile: config })\n    const store = await Store.load('store.json');\n    await store.set('chatToolbarConfigMobile', config)\n    await store.save()\n  },\n\n  // 记录工具栏配置\n  recordToolbarConfig: [\n    { id: 'text', enabled: true, order: 0 },\n    { id: 'recording', enabled: true, order: 1 },\n    { id: 'scan', enabled: true, order: 2 },\n    { id: 'image', enabled: true, order: 3 },\n    { id: 'link', enabled: true, order: 4 },\n    { id: 'file', enabled: true, order: 5 },\n    { id: 'todo', enabled: true, order: 6 },\n  ],\n  setRecordToolbarConfig: async (config: RecordToolbarItem[]) => {\n    set({ recordToolbarConfig: config })\n    const store = await Store.load('store.json');\n    await store.set('recordToolbarConfig', config)\n    await store.save()\n  },\n\n  // 摘要设置\n  enableCondense: true,\n  setEnableCondense: async (enabled: boolean) => {\n    set({ enableCondense: enabled })\n    const store = await Store.load('store.json');\n    await store.set('enableCondense', enabled)\n    await store.save()\n  },\n\n  keepLatestCount: 4,\n  setKeepLatestCount: async (count: number) => {\n    set({ keepLatestCount: count })\n    const store = await Store.load('store.json');\n    await store.set('keepLatestCount', count)\n    await store.save()\n  },\n\n  condenseMaxLength: 100,\n  setCondenseMaxLength: async (length: number) => {\n    set({ condenseMaxLength: length })\n    const store = await Store.load('store.json');\n    await store.set('condenseMaxLength', length)\n    await store.save()\n  },\n\n  // 编辑器撤销/重做按钮显示设置 - 默认开启\n  showEditorUndoRedo: true,\n  setShowEditorUndoRedo: async (show: boolean) => {\n    set({ showEditorUndoRedo: show })\n    const store = await Store.load('store.json');\n    await store.set('showEditorUndoRedo', show)\n    await store.save()\n  },\n}))\n\nexport default useSettingStore\n"
  },
  {
    "path": "src/stores/settingsSync.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\nimport { filterSyncData, mergeSyncData } from '@/config/sync-exclusions'\nimport { uploadFile as uploadGithubFile, getFiles as githubGetFiles, decodeBase64ToString } from '@/lib/sync/github'\nimport { uploadFile as uploadGiteeFile, getFiles as giteeGetFiles } from '@/lib/sync/gitee'\nimport { uploadFile as uploadGitlabFile, getFiles as gitlabGetFiles } from '@/lib/sync/gitlab'\nimport { uploadFile as uploadGiteaFile, getFiles as giteaGetFiles } from '@/lib/sync/gitea'\nimport { getRemoteFileContent } from '@/lib/sync/remote-file'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\n\ninterface SettingsSyncState {\n  syncState: boolean\n  setSyncState: (syncState: boolean) => void\n  \n  lastSyncTime: string\n  setLastSyncTime: (lastSyncTime: string) => void\n  \n  uploadSettings: () => Promise<boolean>\n  downloadSettings: () => Promise<boolean>\n}\n\nconst useSettingsSyncStore = create<SettingsSyncState>((set) => ({\n  syncState: false,\n  setSyncState: (syncState) => set({ syncState }),\n  \n  lastSyncTime: '',\n  setLastSyncTime: (lastSyncTime) => set({ lastSyncTime }),\n  \n  /**\n   * 上传配置到远程仓库\n   * 会自动过滤掉不应同步的字段（如工作区路径等）\n   */\n  uploadSettings: async () => {\n    try {\n      const store = await Store.load('store.json')\n      const primaryBackupMethod = await store.get<'github' | 'gitee' | 'gitlab' | 'gitea'>('primaryBackupMethod') || 'github'\n      \n      // 获取所有配置项\n      const allSettings: Record<string, any> = {}\n      const entries = await store.entries()\n      \n      for (const [key, value] of entries) {\n        allSettings[key] = value\n      }\n      \n      // 过滤掉不应同步的字段\n      const syncableSettings = filterSyncData(allSettings)\n      \n      // 转换为 JSON 字符串\n      const content = JSON.stringify(syncableSettings, null, 2)\n      \n      // 转换为 base64\n      const base64Content = btoa(unescape(encodeURIComponent(content)))\n      \n      // 获取仓库名称\n      const repoName = await getSyncRepoName(primaryBackupMethod)\n      \n      // 根据主要备份方式选择上传函数\n      let uploadFile: typeof uploadGithubFile\n      \n      switch (primaryBackupMethod) {\n        case 'gitee':\n          uploadFile = uploadGiteeFile\n          break\n        case 'gitlab':\n          uploadFile = uploadGitlabFile\n          break\n        case 'gitea':\n          uploadFile = uploadGiteaFile\n          break\n        default:\n          uploadFile = uploadGithubFile\n      }\n      \n      // 上传到远程仓库\n      const result = await uploadFile({\n        file: base64Content,\n        filename: 'settings.json',\n        repo: repoName,\n        path: '.data'\n      })\n      \n      if (result) {\n        // 更新最后同步时间\n        const now = new Date().toISOString()\n        set({ lastSyncTime: now })\n        return true\n      }\n      \n      return false\n    } catch (error) {\n      console.error('Failed to upload settings:', error)\n      return false\n    }\n  },\n  \n  /**\n   * 从远程仓库下载配置\n   * 会保留本地的排除字段（如工作区路径等）\n   */\n  downloadSettings: async () => {\n    try {\n      const store = await Store.load('store.json')\n      const primaryBackupMethod = await store.get<'github' | 'gitee' | 'gitlab' | 'gitea'>('primaryBackupMethod') || 'github'\n      \n      // 获取本地配置（用于保留排除字段）\n      const localSettings: Record<string, any> = {}\n      const entries = await store.entries()\n      \n      for (const [key, value] of entries) {\n        localSettings[key] = value\n      }\n      \n      // 获取仓库名称\n      const repoName = await getSyncRepoName(primaryBackupMethod)\n      \n      // 根据主要备份方式选择获取函数\n      let getFiles: typeof githubGetFiles\n      \n      switch (primaryBackupMethod) {\n        case 'gitee':\n          getFiles = giteeGetFiles\n          break\n        case 'gitlab':\n          getFiles = gitlabGetFiles\n          break\n        case 'gitea':\n          getFiles = giteaGetFiles\n          break\n        default:\n          getFiles = githubGetFiles\n      }\n      \n      // 从远程仓库获取配置文件\n      const files = await getFiles({\n        path: '.data/settings.json',\n        repo: repoName\n      })\n      \n      if (!files) {\n        console.warn('No settings file found in remote repository')\n        return false\n      }\n      \n      // 解码 base64 内容\n      const content = decodeBase64ToString(getRemoteFileContent(files, '.data/settings.json'))\n      const remoteSettings = JSON.parse(content)\n      \n      // 合并配置：使用远程配置，但保留本地的排除字段\n      const mergedSettings = mergeSyncData(localSettings, remoteSettings)\n      \n      // 保存合并后的配置到本地\n      for (const [key, value] of Object.entries(mergedSettings)) {\n        await store.set(key, value)\n      }\n      await store.save()\n      \n      // 更新最后同步时间\n      const now = new Date().toISOString()\n      set({ lastSyncTime: now })\n      \n      return true\n    } catch (error) {\n      console.error('Failed to download settings:', error)\n      return false\n    }\n  }\n}))\n\nexport default useSettingsSyncStore\n"
  },
  {
    "path": "src/stores/shortcut.ts",
    "content": "import { create } from 'zustand';\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { register, unregisterAll } from '@tauri-apps/plugin-global-shortcut';\nimport emitter from '@/lib/emitter';\n\ninterface Shortcut {\n  key: string,\n  value: string,\n}\n\ninterface SettingState {\n  shortcuts: Shortcut[],\n  initShortcut: () => Promise<void>,\n  setShortcut: (key: string, value: string) => Promise<void>,\n  resetDefault: (key: string) => Promise<void>,\n}\n\nconst defaultShortcuts: Shortcut[] = [\n  {\n    key: \"openWindow\",\n    value: \"CommandOrControl+Shift+W\"\n  },\n  {\n    key: 'quickRecordText',\n    value: 'CommandOrControl+Shift+T'\n  }\n]\n\nasync function bindShortcut(shortcut: Shortcut) {\n  await unregisterAll()\n  try {\n    if (shortcut.value) {\n      await register(shortcut.value, (event) => {\n        if (event.state === 'Pressed') {\n          emitter.emit(shortcut.key)\n        }\n      });\n    }\n  } catch (error) {\n    console.error(`Failed to register shortcut ${shortcut.value}:`, error);\n  }\n}\n\nconst useShortcutStore = create<SettingState>((set, get) => ({\n  shortcuts: [],\n\n  initShortcut: async () => {\n    const store = await Store.load('store.json');\n    const shortcuts = await store.get<Shortcut[]>('shortcuts')\n    if (shortcuts && shortcuts.length) {\n      const mergeShortcuts = defaultShortcuts.map((shortcut) => {\n        const existShortcut = shortcuts.find((shortcutItem) => shortcutItem.key === shortcut.key)\n        if (existShortcut) {\n          return existShortcut\n        } else {\n          return shortcut\n        }\n      })\n      set({ shortcuts: mergeShortcuts })\n      mergeShortcuts.forEach(async (shortcut) => {\n        await bindShortcut(shortcut)\n      })\n    } else {\n      await store.set('shortcuts', defaultShortcuts)\n      set({ shortcuts: defaultShortcuts })\n      defaultShortcuts.forEach(async (shortcut) => {\n        await bindShortcut(shortcut)\n      })\n    }\n  },\n\n  setShortcut: async (key: string, value: string) => {\n    const store = await Store.load('store.json');\n    const newShortcuts = get().shortcuts.map((shortcut) => {\n      if (shortcut.key === key) {\n        return { ...shortcut, value }\n      }\n      return shortcut\n    })\n    await store.set('shortcuts', newShortcuts)\n    set({ shortcuts: newShortcuts })\n    newShortcuts.forEach(async (shortcut: Shortcut) => {\n      await bindShortcut(shortcut)\n    })\n  },\n\n  resetDefault: async (key: string) => {\n    const store = await Store.load('store.json');\n    const newShortcuts = get().shortcuts.map((shortcut) => {\n      if (shortcut.key === key) {\n        return { ...shortcut, value: defaultShortcuts.find((shortcut) => shortcut.key === key)?.value || '' }\n      }\n      return shortcut\n    })\n    await store.set('shortcuts', newShortcuts)\n    set({ shortcuts: newShortcuts })\n    newShortcuts.forEach(async (shortcut: Shortcut) => {\n      await bindShortcut(shortcut)\n    })\n  },\n}))\n\nexport default useShortcutStore"
  },
  {
    "path": "src/stores/sidebar.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\n\n\nexport interface SidebarState {\n  fileSidebarVisible: boolean\n  toggleFileSidebar: () => Promise<void>\n  showFileSidebar: () => Promise<void>\n  noteSidebarVisible: boolean\n  toggleNoteSidebar: () => Promise<void>\n  showNoteSidebar: () => Promise<void>\n  leftSidebarVisible: boolean\n  toggleLeftSidebar: () => Promise<void>\n  centerPanelVisible: boolean\n  toggleCenterPanel: () => Promise<void>\n  rightSidebarVisible: boolean\n  toggleRightSidebar: () => Promise<void>\n  leftSidebarTab: 'files' | 'notes'\n  setLeftSidebarTab: (tab: 'files' | 'notes') => Promise<void>\n  initSidebarState: () => Promise<void>\n}\n\n// 从 localStorage 获取初始状态\nconst getInitialState = () => {\n  if (typeof window === 'undefined') return { left: true, center: true, right: true }\n  \n  const leftState = localStorage.getItem('leftSidebarVisible')\n  const centerState = localStorage.getItem('centerPanelVisible')\n  const rightState = localStorage.getItem('rightSidebarVisible')\n  \n  return {\n    left: leftState !== null ? leftState === 'true' : true,\n    center: centerState !== null ? centerState === 'true' : true,\n    right: rightState !== null ? rightState === 'true' : true,\n  }\n}\n\nconst initialState = getInitialState()\n\nexport const useSidebarStore = create<SidebarState>((set, get) => ({\n  fileSidebarVisible: true,\n  toggleFileSidebar: async () => {\n    set((state) => ({\n      fileSidebarVisible: !state.fileSidebarVisible\n    }))\n    const store = await Store.load('store.json')\n    store.set('fileSidebarVisible', !store.get('fileSidebarVisible'))\n  },\n  showFileSidebar: async () => {\n    set({ fileSidebarVisible: true })\n    const store = await Store.load('store.json')\n    store.set('fileSidebarVisible', true)\n  },\n  noteSidebarVisible: true,\n  toggleNoteSidebar: async () => {\n    set((state) => ({\n      noteSidebarVisible: !state.noteSidebarVisible\n    }))\n    const store = await Store.load('store.json')\n    store.set('noteSidebarVisible', !store.get('noteSidebarVisible'))\n  },\n  showNoteSidebar: async () => {\n    set({ noteSidebarVisible: true })\n    const store = await Store.load('store.json')\n    store.set('noteSidebarVisible', true)\n  },\n  leftSidebarVisible: initialState.left,\n  toggleLeftSidebar: async () => {\n    const { leftSidebarVisible, centerPanelVisible, rightSidebarVisible } = get()\n    \n    // 计算当前可见的面板数量\n    const visibleCount = [leftSidebarVisible, centerPanelVisible, rightSidebarVisible].filter(Boolean).length\n    \n    // 如果要关闭左侧面板，需要确保关闭后不会变成\"仅左\"状态（这是不可能的，因为关闭左侧）\n    // 但要确保不会变成无面板状态\n    if (leftSidebarVisible && visibleCount === 1) {\n      return // 不允许关闭最后一个面板\n    }\n    \n    // 如果要打开左侧面板，总是允许\n    const newState = !leftSidebarVisible\n    set({ leftSidebarVisible: newState })\n    localStorage.setItem('leftSidebarVisible', String(newState))\n    const store = await Store.load('store.json')\n    await store.set('leftSidebarVisible', newState)\n    await store.save()\n  },\n  centerPanelVisible: initialState.center,\n  toggleCenterPanel: async () => {\n    const { leftSidebarVisible, centerPanelVisible, rightSidebarVisible } = get()\n    \n    // 计算当前可见的面板数量\n    const visibleCount = [leftSidebarVisible, centerPanelVisible, rightSidebarVisible].filter(Boolean).length\n    \n    // 如果要关闭中间面板，需要确保关闭后不会变成\"仅左\"状态\n    if (centerPanelVisible && visibleCount === 2 && leftSidebarVisible && !rightSidebarVisible) {\n      return // 不允许关闭，否则会变成\"仅左\"状态\n    }\n    \n    // 如果要关闭中间面板，也要确保不会变成无面板状态\n    if (centerPanelVisible && visibleCount === 1) {\n      return // 不允许关闭最后一个面板\n    }\n    \n    // 如果要打开中间面板，总是允许\n    const newState = !centerPanelVisible\n    set({ centerPanelVisible: newState })\n    localStorage.setItem('centerPanelVisible', String(newState))\n    const store = await Store.load('store.json')\n    await store.set('centerPanelVisible', newState)\n    await store.save()\n  },\n  rightSidebarVisible: initialState.right,\n  toggleRightSidebar: async () => {\n    const { leftSidebarVisible, centerPanelVisible, rightSidebarVisible } = get()\n    \n    // 计算当前可见的面板数量\n    const visibleCount = [leftSidebarVisible, centerPanelVisible, rightSidebarVisible].filter(Boolean).length\n    \n    // 如果要关闭右侧面板，需要确保关闭后不会变成\"仅左\"状态\n    if (rightSidebarVisible && visibleCount === 2 && leftSidebarVisible && !centerPanelVisible) {\n      return // 不允许关闭，否则会变成\"仅左\"状态\n    }\n    \n    // 如果要关闭右侧面板，也要确保不会变成无面板状态\n    if (rightSidebarVisible && visibleCount === 1) {\n      return // 不允许关闭最后一个面板\n    }\n    \n    // 如果要打开右侧面板，总是允许\n    const newState = !rightSidebarVisible\n    set({ rightSidebarVisible: newState })\n    localStorage.setItem('rightSidebarVisible', String(newState))\n    const store = await Store.load('store.json')\n    await store.set('rightSidebarVisible', newState)\n    await store.save()\n  },\n  leftSidebarTab: 'files',\n  setLeftSidebarTab: async (tab: 'files' | 'notes') => {\n    set({ leftSidebarTab: tab })\n    localStorage.setItem('leftSidebarTab', tab)\n    const store = await Store.load('store.json')\n    await store.set('leftSidebarTab', tab)\n    await store.save()\n  },\n  initSidebarState: async () => {\n    const store = await Store.load('store.json')\n    const leftState = await store.get<boolean>('leftSidebarVisible')\n    const centerState = await store.get<boolean>('centerPanelVisible')\n    const rightState = await store.get<boolean>('rightSidebarVisible')\n    const leftTab = await store.get<'files' | 'notes'>('leftSidebarTab')\n    \n    if (leftState !== null && leftState !== undefined) {\n      set({ leftSidebarVisible: leftState })\n      localStorage.setItem('leftSidebarVisible', String(leftState))\n    }\n    if (centerState !== null && centerState !== undefined) {\n      set({ centerPanelVisible: centerState })\n      localStorage.setItem('centerPanelVisible', String(centerState))\n    }\n    if (rightState !== null && rightState !== undefined) {\n      set({ rightSidebarVisible: rightState })\n      localStorage.setItem('rightSidebarVisible', String(rightState))\n    }\n    if (leftTab) {\n      set({ leftSidebarTab: leftTab })\n      localStorage.setItem('leftSidebarTab', leftTab)\n    }\n  },\n}))\n"
  },
  {
    "path": "src/stores/skills.ts",
    "content": "import { create } from 'zustand'\nimport { Store } from '@tauri-apps/plugin-store'\nimport type { SkillMetadata, SkillContent, SkillExecutionRecord } from '@/lib/skills/types'\nimport { skillManager } from '@/lib/skills/manager'\n\ninterface SkillsState {\n  // 配置\n  enabled: boolean\n  autoMatch: boolean              // 是否自动匹配 Skills\n\n  // Skills\n  skills: SkillMetadata[]\n  globalSkills: SkillMetadata[]   // 全局 Skills\n  projectSkills: SkillMetadata[]  // 工作区 Skills\n\n  // 运行时\n  activeSkill: string | null      // 当前活跃的 Skill\n  skillHistory: SkillExecutionRecord[]\n\n  // 是否已初始化\n  initialized: boolean\n  initializing: boolean  // 是否正在初始化，防止重复初始化\n\n  // 方法\n  initSkills: () => Promise<void>\n  loadSkillsConfig: () => Promise<void>\n\n  // 配置管理\n  setEnabled: (enabled: boolean) => Promise<void>\n  setAutoMatch: (autoMatch: boolean) => Promise<void>\n\n  // Skill 管理方法\n  toggleSkill: (id: string) => Promise<void>\n  updateSkillInstructions: (id: string, instructions: string) => Promise<void>\n  deleteSkill: (id: string) => Promise<void>\n  refreshSkills: () => Promise<void>\n\n  // 获取方法\n  getSkill: (id: string) => SkillContent | undefined\n  getEnabledSkills: () => Promise<SkillContent[]>\n  getUserInvocableSkills: () => SkillContent[]\n  getSkillsByScope: (scope: 'global' | 'project') => SkillContent[]\n\n  // 执行历史\n  addExecutionRecord: (record: SkillExecutionRecord) => void\n  clearExecutionHistory: () => void\n}\n\nexport const useSkillsStore = create<SkillsState>((set, get) => ({\n  // 初始状态\n  enabled: true,  // 默认启用\n  autoMatch: true,\n  skills: [],\n  globalSkills: [],\n  projectSkills: [],\n  activeSkill: null,\n  skillHistory: [],\n  initialized: false,\n  initializing: false,  // 防止重复初始化\n\n  // 初始化 Skills\n  initSkills: async () => {\n    const state = get()\n\n    // 防止重复初始化\n    if (state.initializing) {\n      // 等待正在进行的初始化完成\n      while (get().initializing) {\n        await new Promise(resolve => setTimeout(resolve, 100))\n      }\n      return\n    }\n\n    // 如果已经初始化过，只加载配置\n    if (state.initialized) {\n      await get().loadSkillsConfig()\n      return\n    }\n\n    try {\n      set({ initializing: true })\n\n      const store = await Store.load('store.json')\n      const enabled = await store.get<boolean>('skills.enabled')\n      const autoMatch = await store.get<boolean>('skills.autoMatch')\n\n      // 先设置配置，不设置 initialized\n      set({\n        enabled: enabled ?? true,  // 默认为 true\n        autoMatch: autoMatch ?? true,\n      })\n\n      // 初始化 Skill 管理器\n      await skillManager.initialize()\n\n      // 加载 Skills 到状态\n      await get().refreshSkills()\n\n      // 只有成功完成所有初始化后才设置 initialized 为 true\n      set({ initialized: true })\n    } catch (error) {\n      console.error('Failed to initialize Skills:', error)\n      // 初始化失败，重置状态\n      set({ initialized: false })\n    } finally {\n      set({ initializing: false })\n    }\n  },\n\n  // 加载 Skills 配置\n  loadSkillsConfig: async () => {\n    try {\n      const store = await Store.load('store.json')\n      const enabled = await store.get<boolean>('skills.enabled')\n      const autoMatch = await store.get<boolean>('skills.autoMatch')\n\n      set({\n        enabled: enabled ?? false,\n        autoMatch: autoMatch ?? true,\n      })\n    } catch (error) {\n      console.error('Failed to load Skills config:', error)\n    }\n  },\n\n  // 设置启用状态\n  setEnabled: async (enabled: boolean) => {\n    const store = await Store.load('store.json')\n    await store.set('skills.enabled', enabled)\n    await store.save()\n    set({ enabled })\n  },\n\n  // 设置自动匹配\n  setAutoMatch: async (autoMatch: boolean) => {\n    const store = await Store.load('store.json')\n    await store.set('skills.autoMatch', autoMatch)\n    await store.save()\n    set({ autoMatch })\n  },\n\n  // 刷新 Skills 列表\n  refreshSkills: async () => {\n    await skillManager.reload()\n\n    const allSkills = skillManager.getAllSkills()\n    const globalSkills = skillManager.getSkillsByScope('global')\n    const projectSkills = skillManager.getSkillsByScope('project')\n\n    set({\n      skills: allSkills.map(s => s.metadata),\n      globalSkills: globalSkills.map(s => s.metadata),\n      projectSkills: projectSkills.map(s => s.metadata),\n    })\n  },\n\n  // 切换 Skill 启用状态\n  toggleSkill: async (id: string) => {\n    const skill = skillManager.getSkill(id)\n    if (!skill) return\n\n    // 更新 Skill 的启用状态\n    skill.metadata.enabled = !skill.metadata.enabled\n    skill.metadata.updatedAt = Date.now()\n\n    // 保存到本地存储\n    const store = await Store.load('store.json')\n    const enabledSkills = await store.get<Record<string, boolean>>('skills.enabledSkills') || {}\n    enabledSkills[id] = skill.metadata.enabled\n    await store.set('skills.enabledSkills', enabledSkills)\n    await store.save()\n\n    // 更新状态\n    await get().refreshSkills()\n  },\n\n  // 更新 Skill 指令\n  updateSkillInstructions: async (id: string, instructions: string) => {\n    const skill = skillManager.getSkill(id)\n    if (!skill) return\n\n    // 更新内存中的指令内容\n    skill.instructions = instructions\n    skill.metadata.updatedAt = Date.now()\n\n    // 获取 Skill 文件信息\n    const fileInfo = skillManager.getSkillFileInfo(id)\n    if (!fileInfo) return\n\n    // 构建完整的 Skill 文件内容\n    const metadata = skill.metadata\n    const yamlMetadata = `---\nname: ${metadata.name}\ndescription: ${metadata.description}\nversion: ${metadata.version}\n${metadata.author ? `author: ${metadata.author}` : ''}\n${metadata.allowedTools ? `allowedTools:\\n${metadata.allowedTools.map(t => `  - ${t}`).join('\\n')}` : ''}\n${metadata.userInvocable !== undefined ? `userInvocable: ${metadata.userInvocable}` : ''}\n---\n\n${instructions}\n`\n\n    // 写入文件\n    const { writeTextFile, BaseDirectory } = await import('@tauri-apps/plugin-fs')\n\n    if (metadata.scope === 'global') {\n      await writeTextFile(fileInfo.mainFile, yamlMetadata, { baseDir: BaseDirectory.AppData })\n    } else {\n      const { getFilePathOptions } = await import('@/lib/workspace')\n      const options = await getFilePathOptions(fileInfo.mainFile)\n      if (options.baseDir) {\n        await writeTextFile(options.path, yamlMetadata, { baseDir: options.baseDir })\n      } else {\n        await writeTextFile(options.path, yamlMetadata)\n      }\n    }\n\n    // 更新状态\n    await get().refreshSkills()\n  },\n\n  // 删除 Skill\n  deleteSkill: async (id: string) => {\n    const skill = skillManager.getSkill(id)\n    const fileInfo = skillManager.getSkillFileInfo(id)\n    if (!skill || !fileInfo) return\n\n    // 删除目录\n    const { remove } = await import('@tauri-apps/plugin-fs')\n    const { BaseDirectory } = await import('@tauri-apps/plugin-fs')\n\n    if (skill.metadata.scope === 'global') {\n      // fileInfo.directory 已经是完整路径（如 skills/style-detector）\n      await remove(fileInfo.directory, { baseDir: BaseDirectory.AppData, recursive: true })\n    } else {\n      const { getFilePathOptions } = await import('@/lib/workspace')\n      const options = await getFilePathOptions(fileInfo.directory)\n      if (options.baseDir) {\n        await remove(options.path, { baseDir: options.baseDir, recursive: true })\n      } else {\n        await remove(options.path, { recursive: true })\n      }\n    }\n\n    // 从管理器中注销 Skill\n    skillManager.unregisterSkill(id)\n\n    // 更新状态\n    await get().refreshSkills()\n  },\n\n  // 获取 Skill\n  getSkill: (id: string) => {\n    return skillManager.getSkill(id)\n  },\n\n  // 获取已启用的 Skills\n  getEnabledSkills: async () => {\n    return await skillManager.getEnabledSkills()\n  },\n\n  // 获取可用户调用的 Skills\n  getUserInvocableSkills: () => {\n    return skillManager.getUserInvocableSkills()\n  },\n\n  // 按作用域获取 Skills\n  getSkillsByScope: (scope: 'global' | 'project') => {\n    return skillManager.getSkillsByScope(scope)\n  },\n\n  // 添加执行记录\n  addExecutionRecord: (record: SkillExecutionRecord) => {\n    const history = get().skillHistory\n    const newHistory = [record, ...history].slice(0, 100) // 保留最近 100 条\n    set({ skillHistory: newHistory })\n  },\n\n  // 清除执行历史\n  clearExecutionHistory: () => {\n    set({ skillHistory: [] })\n  },\n}))\n"
  },
  {
    "path": "src/stores/speech-recognition.ts",
    "content": "import { create } from 'zustand'\n\n// 浏览器语音识别 API 类型定义\ninterface SpeechRecognitionEvent extends Event {\n  results: SpeechRecognitionResultList\n  resultIndex: number\n}\n\ninterface SpeechRecognitionResultList {\n  length: number\n  item(index: number): SpeechRecognitionResult\n  [index: number]: SpeechRecognitionResult\n}\n\ninterface SpeechRecognitionResult {\n  isFinal: boolean\n  length: number\n  item(index: number): SpeechRecognitionAlternative\n  [index: number]: SpeechRecognitionAlternative\n}\n\ninterface SpeechRecognitionAlternative {\n  transcript: string\n  confidence: number\n}\n\ninterface SpeechRecognition extends EventTarget {\n  continuous: boolean\n  interimResults: boolean\n  lang: string\n  maxAlternatives: number\n  start(): void\n  stop(): void\n  abort(): void\n  onerror: ((event: any) => void) | null\n  onresult: ((event: SpeechRecognitionEvent) => void) | null\n  onend: (() => void) | null\n  onstart: (() => void) | null\n}\n\ndeclare global {\n  interface Window {\n    SpeechRecognition: new () => SpeechRecognition\n    webkitSpeechRecognition: new () => SpeechRecognition\n  }\n}\n\ninterface SpeechRecognitionState {\n  // 识别状态\n  isRecognizing: boolean\n  transcript: string // 识别的文本\n  interimTranscript: string // 临时文本（实时）\n  lastError: string | null // 最后的错误类型\n  \n  // 识别实例\n  recognition: SpeechRecognition | null\n  \n  // 控制方法\n  startRecognition: (language?: string) => Promise<void>\n  stopRecognition: () => Promise<string>\n  \n  // 内部方法\n  resetState: () => void\n  \n  // 检查浏览器支持\n  isSupported: () => boolean\n}\n\nconst useSpeechRecognitionStore = create<SpeechRecognitionState>((set, get) => ({\n  isRecognizing: false,\n  transcript: '',\n  interimTranscript: '',\n  lastError: null,\n  recognition: null,\n\n  isSupported: () => {\n    return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window\n  },\n\n  startRecognition: async (language = 'zh-CN') => {\n    try {\n      // 检查浏览器支持\n      if (!get().isSupported()) {\n        throw new Error('当前浏览器不支持语音识别功能，请使用 Chrome、Edge 或 Safari')\n      }\n\n      // 创建识别实例\n      const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition\n      const recognition = new SpeechRecognitionAPI()\n\n      // 配置识别选项\n      recognition.continuous = true // 持续识别\n      recognition.interimResults = true // 实时结果\n      recognition.lang = language // 语言设置\n      recognition.maxAlternatives = 1 // 最多返回1个结果\n\n      let startupPending = true\n\n      // 识别结果处理\n      recognition.onresult = (event: SpeechRecognitionEvent) => {\n        let interimTranscript = ''\n        let finalTranscript = ''\n\n        for (let i = event.resultIndex; i < event.results.length; i++) {\n          const result = event.results[i]\n          const transcript = result[0].transcript\n\n          if (result.isFinal) {\n            finalTranscript += transcript\n          } else {\n            interimTranscript += transcript\n          }\n        }\n\n        set({\n          transcript: get().transcript + finalTranscript,\n          interimTranscript\n        })\n      }\n\n      // 错误处理\n      recognition.onerror = (event: any) => {\n        console.error('语音识别错误:', event.error, event)\n        \n        // 重置状态\n        get().resetState()\n        \n        // 记录错误类型，供外部判断\n        set({ \n          isRecognizing: false,\n          lastError: event.error \n        })\n      }\n\n      // 识别结束处理\n      recognition.onend = () => {\n        set({ isRecognizing: false })\n      }\n\n      await new Promise<void>((resolve, reject) => {\n        set({\n          recognition,\n          isRecognizing: true,\n          transcript: '',\n          interimTranscript: '',\n          lastError: null,\n        })\n\n        recognition.onstart = () => {\n          startupPending = false\n          resolve()\n        }\n\n        recognition.onerror = (event: any) => {\n          console.error('语音识别错误:', event.error, event)\n\n          get().resetState()\n\n          set({\n            isRecognizing: false,\n            lastError: event.error\n          })\n\n          if (startupPending) {\n            reject(new Error(event.error || 'speech-recognition-error'))\n            return\n          }\n        }\n\n        try {\n          recognition.start()\n        } catch (startError) {\n          console.error('启动识别失败:', startError)\n          reject(startError)\n        }\n      })\n\n    } catch (error) {\n      console.error('启动语音识别失败:', error)\n      throw error\n    }\n  },\n\n  stopRecognition: async () => {\n    const { recognition } = get()\n\n    if (!recognition) {\n      return `${get().transcript}${get().interimTranscript}`.trim()\n    }\n\n    return new Promise((resolve) => {\n      const originalOnEnd = recognition.onend\n\n      recognition.onend = () => {\n        originalOnEnd?.()\n\n        const finalTranscript = `${get().transcript}${get().interimTranscript}`.trim()\n\n        set({\n          isRecognizing: false,\n          interimTranscript: ''\n        })\n\n        resolve(finalTranscript)\n      }\n\n      recognition.stop()\n    })\n  },\n\n  resetState: () => {\n    const { recognition } = get()\n    \n    if (recognition) {\n      recognition.abort()\n    }\n    \n    set({\n      isRecognizing: false,\n      transcript: '',\n      interimTranscript: '',\n      recognition: null\n    })\n  }\n}))\n\nexport default useSpeechRecognitionStore\n"
  },
  {
    "path": "src/stores/sync-confirm.ts",
    "content": "import { create } from 'zustand'\n\ninterface SyncConfirmState {\n  isOpen: boolean\n  dialogType: 'pull' | 'conflict' | 'shaMismatch'  // 对话框类型：拉取确认 | 冲突解决 | SHA 不匹配\n  fileName: string\n  localContent?: string\n  remoteContent?: string\n  localSha?: string      // 本地记录的 SHA（SHA 不匹配时使用）\n  remoteSha?: string    // 远程文件的 SHA（SHA 不匹配时使用）\n  commitInfo?: {\n    sha: string\n    message: string\n    author: string\n    date: Date\n    additions?: number\n    deletions?: number\n  }\n  onConfirm?: () => void  // 确认（拉取/保留远程）\n  onCancel?: () => void   // 取消\n  onKeepLocal?: () => void // 保留本地（冲突时）\n  onMerge?: () => void     // 合并（冲突时）\n  onIgnore?: () => void    // 忽略\n\n  // Actions\n  showPullDialog: (data: {\n    fileName: string\n    commitInfo?: {\n      sha: string\n      message: string\n      author: string\n      date: Date\n      additions?: number\n      deletions?: number\n    }\n    onConfirm: () => void\n    onCancel?: () => void\n    onIgnore?: () => void\n  }) => void\n\n  showConflictDialog: (data: {\n    fileName: string\n    localContent: string\n    remoteContent: string\n    commitInfo?: {\n      sha: string\n      message: string\n      author: string\n      date: Date\n    }\n    onKeepLocal: () => void\n    onKeepRemote: () => void\n    onMerge?: () => void\n    onCancel?: () => void\n  }) => void\n\n  // 显示 SHA 不匹配对话框\n  showShaMismatchDialog: (data: {\n    fileName: string\n    localSha?: string\n    remoteSha?: string\n    onForceUpload: () => void  // 强制上传（不带 SHA）\n    onCancel: () => void        // 取消\n  }) => void\n\n  hideConfirmDialog: () => void\n}\n\nexport const useSyncConfirmStore = create<SyncConfirmState>((set) => ({\n  isOpen: false,\n  dialogType: 'pull',\n  fileName: '',\n  localContent: undefined,\n  remoteContent: undefined,\n  commitInfo: undefined,\n  onConfirm: undefined,\n  onCancel: undefined,\n  onKeepLocal: undefined,\n  onMerge: undefined,\n  onIgnore: undefined,\n\n  showPullDialog: (data) => set({\n    isOpen: true,\n    dialogType: 'pull',\n    fileName: data.fileName,\n    commitInfo: data.commitInfo,\n    onConfirm: data.onConfirm,\n    onCancel: data.onCancel,\n    onIgnore: data.onIgnore\n  }),\n\n  showConflictDialog: (data) => set({\n    isOpen: true,\n    dialogType: 'conflict',\n    fileName: data.fileName,\n    localContent: data.localContent,\n    remoteContent: data.remoteContent,\n    commitInfo: data.commitInfo,\n    onKeepLocal: data.onKeepLocal,\n    onConfirm: data.onKeepRemote,  // onConfirm 用于保留远程\n    onMerge: data.onMerge,\n    onCancel: data.onCancel\n  }),\n\n  showShaMismatchDialog: (data) => set({\n    isOpen: true,\n    dialogType: 'shaMismatch',\n    fileName: data.fileName,\n    localSha: data.localSha,\n    remoteSha: data.remoteSha,\n    onConfirm: data.onForceUpload,  // onConfirm 用于强制上传\n    onCancel: data.onCancel\n  }),\n\n  hideConfirmDialog: () => set({\n    isOpen: false,\n    dialogType: 'pull',\n    fileName: '',\n    localContent: undefined,\n    remoteContent: undefined,\n    localSha: undefined,\n    remoteSha: undefined,\n    commitInfo: undefined,\n    onConfirm: undefined,\n    onCancel: undefined,\n    onKeepLocal: undefined,\n    onMerge: undefined,\n    onIgnore: undefined\n  })\n}))\n"
  },
  {
    "path": "src/stores/sync.ts",
    "content": "import { GithubRepoInfo, UserInfo, SyncStateEnum } from '@/lib/sync/github.types'\nimport { GiteeRepoInfo } from '@/lib/sync/gitee'\nimport { GitlabUserInfo, GitlabProjectInfo } from '@/lib/sync/gitlab.types'\nimport { GiteaUserInfo, GiteaRepositoryInfo } from '@/lib/sync/gitea.types'\nimport { create } from 'zustand'\n\ninterface SyncState {\n  // Github 相关状态\n  userInfo?: UserInfo\n  setUserInfo: (userInfo?: UserInfo) => void\n\n  syncRepoState: SyncStateEnum\n  setSyncRepoState: (syncRepoState: SyncStateEnum) => void\n  syncRepoInfo?: GithubRepoInfo\n  setSyncRepoInfo: (syncRepoInfo?: GithubRepoInfo) => void\n\n  // Gitee 相关状态\n  giteeUserInfo?: any\n  setGiteeUserInfo: (giteeUserInfo?: any) => void\n\n  giteeSyncRepoState: SyncStateEnum\n  setGiteeSyncRepoState: (giteeSyncRepoState: SyncStateEnum) => void\n  giteeSyncRepoInfo?: GiteeRepoInfo\n  setGiteeSyncRepoInfo: (giteeSyncRepoInfo?: GiteeRepoInfo) => void\n\n  // Gitlab 相关状态\n  gitlabUserInfo?: GitlabUserInfo\n  setGitlabUserInfo: (gitlabUserInfo?: GitlabUserInfo) => void\n\n  gitlabSyncProjectState: SyncStateEnum\n  setGitlabSyncProjectState: (gitlabSyncProjectState: SyncStateEnum) => void\n  gitlabSyncProjectInfo?: GitlabProjectInfo\n  setGitlabSyncProjectInfo: (gitlabSyncProjectInfo?: GitlabProjectInfo) => void\n\n  // Gitea 相关状态\n  giteaUserInfo?: GiteaUserInfo\n  setGiteaUserInfo: (giteaUserInfo?: GiteaUserInfo) => void\n\n  giteaSyncRepoState: SyncStateEnum\n  setGiteaSyncRepoState: (giteaSyncRepoState: SyncStateEnum) => void\n  giteaSyncRepoInfo?: GiteaRepositoryInfo\n  setGiteaSyncRepoInfo: (giteaSyncRepoInfo?: GiteaRepositoryInfo) => void\n\n  // S3 相关状态\n  s3Connected: boolean\n  setS3Connected: (connected: boolean) => void\n\n  s3FileEtags: Record<string, string>\n  setS3FileEtags: (etags: Record<string, string>) => void\n  updateS3FileEtag: (path: string, etag: string) => void\n  removeS3FileEtag: (path: string) => void\n\n  // WebDAV 相关状态\n  webdavConnected: boolean\n  setWebDAVConnected: (connected: boolean) => void\n\n  webdavFileEtags: Record<string, string>\n  setWebDAVFileEtags: (etags: Record<string, string>) => void\n  updateWebDAVFileEtag: (path: string, etag: string) => void\n  removeWebDAVFileEtag: (path: string) => void\n}\n\nconst useSyncStore = create<SyncState>((set) => ({\n  // Github 相关状态\n  userInfo: undefined,\n  setUserInfo: (userInfo) => {\n    set({ userInfo })\n  },\n\n  syncRepoState: SyncStateEnum.fail,\n  setSyncRepoState: (syncRepoState) => {\n    set({ syncRepoState })\n  },\n  syncRepoInfo: undefined,\n  setSyncRepoInfo: (syncRepoInfo) => {\n    set({ syncRepoInfo })\n  },\n\n  // Gitee 相关状态\n  giteeUserInfo: undefined,\n  setGiteeUserInfo: (giteeUserInfo) => {\n    set({ giteeUserInfo })\n  },\n\n  giteeSyncRepoState: SyncStateEnum.fail,\n  setGiteeSyncRepoState: (giteeSyncRepoState) => {\n    set({ giteeSyncRepoState })\n  },\n  giteeSyncRepoInfo: undefined,\n  setGiteeSyncRepoInfo: (giteeSyncRepoInfo) => {\n    set({ giteeSyncRepoInfo })\n  },\n\n  // Gitlab 相关状态\n  gitlabUserInfo: undefined,\n  setGitlabUserInfo: (gitlabUserInfo) => {\n    set({ gitlabUserInfo })\n  },\n\n  gitlabSyncProjectState: SyncStateEnum.fail,\n  setGitlabSyncProjectState: (gitlabSyncProjectState) => {\n    set({ gitlabSyncProjectState })\n  },\n  gitlabSyncProjectInfo: undefined,\n  setGitlabSyncProjectInfo: (gitlabSyncProjectInfo) => {\n    set({ gitlabSyncProjectInfo })\n  },\n\n  // Gitea 相关状态\n  giteaUserInfo: undefined,\n  setGiteaUserInfo: (giteaUserInfo) => {\n    set({ giteaUserInfo })\n  },\n\n  giteaSyncRepoState: SyncStateEnum.fail,\n  setGiteaSyncRepoState: (giteaSyncRepoState) => {\n    set({ giteaSyncRepoState })\n  },\n  giteaSyncRepoInfo: undefined,\n  setGiteaSyncRepoInfo: (giteaSyncRepoInfo) => {\n    set({ giteaSyncRepoInfo })\n  },\n\n  // S3 相关状态\n  s3Connected: false,\n  setS3Connected: (connected) => {\n    set({ s3Connected: connected })\n  },\n\n  s3FileEtags: {},\n  setS3FileEtags: (etags) => {\n    set({ s3FileEtags: etags })\n  },\n  updateS3FileEtag: (path, etag) => {\n    set((state) => ({\n      s3FileEtags: { ...state.s3FileEtags, [path]: etag },\n    }))\n  },\n  removeS3FileEtag: (path) => {\n    set((state) => {\n      const newEtags = { ...state.s3FileEtags }\n      delete newEtags[path]\n      return { s3FileEtags: newEtags }\n    })\n  },\n\n  // WebDAV 相关状态\n  webdavConnected: false,\n  setWebDAVConnected: (connected) => {\n    set({ webdavConnected: connected })\n  },\n\n  webdavFileEtags: {},\n  setWebDAVFileEtags: (etags) => {\n    set({ webdavFileEtags: etags })\n  },\n  updateWebDAVFileEtag: (path, etag) => {\n    set((state) => ({\n      webdavFileEtags: { ...state.webdavFileEtags, [path]: etag },\n    }))\n  },\n  removeWebDAVFileEtag: (path) => {\n    set((state) => {\n      const newEtags = { ...state.webdavFileEtags }\n      delete newEtags[path]\n      return { webdavFileEtags: newEtags }\n    })\n  },\n}))\n\nexport default useSyncStore"
  },
  {
    "path": "src/stores/tag.ts",
    "content": "import { Tag, delTag, getTags, insertTags, deleteAllTags } from '@/db/tags'\nimport { uploadFile as uploadGithubFile, getFiles as githubGetFiles, decodeBase64ToString } from '@/lib/sync/github'\nimport { uploadFile as uploadGiteeFile, getFiles as giteeGetFiles } from '@/lib/sync/gitee'\nimport { uploadFile as uploadGitlabFile, getFiles as gitlabGetFiles, getFileContent as gitlabGetFileContent } from '@/lib/sync/gitlab'\nimport { uploadFile as uploadGiteaFile, getFiles as giteaGetFiles, getFileContent as giteaGetFileContent } from '@/lib/sync/gitea'\nimport { s3Upload, s3Delete, s3HeadObject, s3Download } from '@/lib/sync/s3'\nimport { webdavUpload, webdavDelete, webdavHeadObject, webdavDownload } from '@/lib/sync/webdav'\nimport { getSyncRepoName } from '@/lib/sync/repo-utils'\nimport { getRemoteFileContent } from '@/lib/sync/remote-file'\nimport { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\nimport { S3Config, WebDAVConfig } from '@/types/sync'\n\ninterface TagState {\n  currentTagId: number\n  setCurrentTagId: (id: number) => Promise<void>\n  initTags: () => Promise<void>\n\n  currentTag?: Tag\n  getCurrentTag: () => void\n\n  tags: Tag[]\n  fetchTags: () => Promise<void>\n\n  deleteTag: (id: number) => Promise<void>\n\n  // 同步\n  syncState: boolean\n  setSyncState: (syncState: boolean) => void\n  lastSyncTime: string\n  setLastSyncTime: (lastSyncTime: string) => void\n  uploadTags: () => Promise<boolean>\n  downloadTags: () => Promise<Tag[]>\n}\n\nconst useTagStore = create<TagState>((set, get) => ({\n  // 当前选择的 tag\n  currentTagId: 1,\n  setCurrentTagId: async(currentTagId: number) => {\n    set({ currentTagId })\n    const store = await Store.load('store.json');\n    await store.set('currentTagId', currentTagId)\n  },\n  initTags: async () => {\n    const store = await Store.load('store.json');\n    const currentTagId = await store.get<number>('currentTagId')\n    if (currentTagId) set({ currentTagId })\n    get().getCurrentTag()\n  },\n\n  currentTag: undefined,\n  getCurrentTag: () => {\n    const tags = get().tags\n    const getcurrentTagId = get().currentTagId\n    const currentTag = tags.find((tag) => tag.id === getcurrentTagId)\n    if (currentTag) {\n      set({ currentTag })\n    }\n  },\n\n  // 所有 tag\n  tags: [],\n  fetchTags: async () => {\n    const tags = await getTags()\n    set({ tags })\n  },\n\n  deleteTag: async (id: number) => {\n    await delTag(id)\n    await get().fetchTags()\n    await get().setCurrentTagId(get().tags[0].id)\n  },\n\n  // 同步\n  syncState: false,\n  setSyncState: (syncState) => {\n    set({ syncState })\n  },\n  lastSyncTime: '',\n  setLastSyncTime: (lastSyncTime) => {\n    set({ lastSyncTime })\n  },\n  uploadTags: async () => {\n    set({ syncState: true })\n    const path = '.data'\n    const filename = 'tags.json'\n    const tags = await getTags()\n    const store = await Store.load('store.json');\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let result = false\n    let res;\n    let files: any;\n    const fullPath = `${path}/${filename}`;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: fullPath, repo: githubRepo })\n        res = await uploadGithubFile({\n          file: JSON.stringify(tags),\n          repo: githubRepo,\n          path: fullPath,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: fullPath, repo: giteeRepo })\n        res = await uploadGiteeFile({\n          file: JSON.stringify(tags),\n          repo: giteeRepo,\n          path: fullPath,\n          sha: files?.sha,\n        })\n        break;\n      case 'gitlab': {\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        files = await gitlabGetFiles({ path, repo: gitlabRepo })\n\n        // 如果目录不存在（files 为 null），先创建目录标记文件\n        if (!files) {\n          try {\n            await uploadGitlabFile({\n              file: '',\n              repo: gitlabRepo,\n              path,\n              filename: '.gitkeep',\n              sha: '',\n            })\n          } catch (e) {\n            console.log('[tag store] GitLab create .gitkeep error:', e)\n          }\n          // 重新获取文件列表\n          files = await gitlabGetFiles({ path, repo: gitlabRepo })\n        }\n\n        const tagFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGitlabFile({\n          file: JSON.stringify(tags),\n          repo: gitlabRepo,\n          path,\n          filename,\n          sha: tagFile?.sha || '',\n        })\n        break;\n      }\n      case 'gitea':\n        const giteaRepo = await getSyncRepoName('gitea')\n        files = await giteaGetFiles({ path, repo: giteaRepo })\n        const giteaTagFile = Array.isArray(files)\n          ? files.find(file => file.name === filename)\n          : (files?.name === filename ? files : undefined)\n        res = await uploadGiteaFile({\n          file: JSON.stringify(tags),\n          repo: giteaRepo,\n          path,\n          filename,\n          sha: giteaTagFile?.sha || '',\n        })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const existingFile = await s3HeadObject(s3Config, s3Key)\n          if (existingFile) {\n            await s3Delete(s3Config, s3Key)\n          }\n          res = await s3Upload(s3Config, s3Key, JSON.stringify(tags))\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const existingFile = await webdavHeadObject(webdavConfig, webdavKey)\n          if (existingFile) {\n            await webdavDelete(webdavConfig, webdavKey)\n          }\n          res = await webdavUpload(webdavConfig, webdavKey, JSON.stringify(tags))\n        }\n        break;\n      }\n    }\n    if (res) {\n      result = true\n    }\n    set({ syncState: false })\n    return result\n  },\n  downloadTags: async () => {\n    const path = '.data'\n    const filename = 'tags.json'\n    const store = await Store.load('store.json');\n    const primaryBackupMethod = await store.get<string>('primaryBackupMethod') || 'github';\n    let result = []\n    let files;\n    switch (primaryBackupMethod) {\n      case 'github':\n        const githubRepo = await getSyncRepoName('github')\n        files = await githubGetFiles({ path: `${path}/${filename}`, repo: githubRepo })\n        break;\n      case 'gitee':\n        const giteeRepo = await getSyncRepoName('gitee')\n        files = await giteeGetFiles({ path: `${path}/${filename}`, repo: giteeRepo })\n        break;\n      case 'gitlab':\n        const gitlabRepo = await getSyncRepoName('gitlab')\n        files = await gitlabGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: gitlabRepo })\n        break;\n      case 'gitea':\n        const giteaRepo2 = await getSyncRepoName('gitea')\n        files = await giteaGetFileContent({ path: `${path}/${filename}`, ref: 'main', repo: giteaRepo2 })\n        break;\n      case 's3': {\n        const s3Config = await store.get<S3Config>('s3SyncConfig')\n        if (s3Config) {\n          const s3Key = `${path}/${filename}`\n          const s3Result = await s3Download(s3Config, s3Key)\n          if (s3Result) {\n            // S3 返回的 content 是字符串，直接解析\n            result = JSON.parse(s3Result.content)\n          }\n        }\n        break;\n      }\n      case 'webdav': {\n        const webdavConfig = await store.get<WebDAVConfig>('webdavSyncConfig')\n        if (webdavConfig) {\n          const webdavKey = `${path}/${filename}`\n          const webdavResult = await webdavDownload(webdavConfig, webdavKey)\n          if (webdavResult) {\n            result = JSON.parse(webdavResult.content)\n          }\n        }\n        break;\n      }\n    }\n    // S3 已经直接解析到 result 了，这里处理 Git 平台\n    if (files) {\n      const configJson = decodeBase64ToString(getRemoteFileContent(files, `${path}/${filename}`))\n      result = JSON.parse(configJson)\n    }\n    if (result.length > 0) {\n      await deleteAllTags()\n      await insertTags(result)\n    }\n    set({ syncState: false })\n    return result\n  },\n}))\n\nexport default useTagStore\n"
  },
  {
    "path": "src/stores/update.ts",
    "content": "import { Store } from '@tauri-apps/plugin-store'\nimport { create } from 'zustand'\nimport { check, Update } from '@tauri-apps/plugin-updater'\n\ninterface UpdateState {\n  hasUpdate: boolean\n  setHasUpdate: (hasUpdate: boolean) => void\n  \n  update: Update | null\n  setUpdate: (update: Update | null) => void\n  \n  latestVersion: string\n  setLatestVersion: (version: string) => void\n  \n  ignoredVersion: string\n  setIgnoredVersion: (version: string) => Promise<void>\n  \n  checkForUpdates: () => Promise<void>\n  ignoreCurrentVersion: () => Promise<void>\n  \n  initUpdateStore: () => Promise<void>\n}\n\nconst useUpdateStore = create<UpdateState>((set, get) => ({\n  hasUpdate: false,\n  setHasUpdate: (hasUpdate) => set({ hasUpdate }),\n  \n  update: null,\n  setUpdate: (update) => set({ update }),\n  \n  latestVersion: '',\n  setLatestVersion: (version) => set({ latestVersion: version }),\n  \n  ignoredVersion: '',\n  setIgnoredVersion: async (version) => {\n    const store = await Store.load('store.json')\n    await store.set('ignoredVersion', version)\n    await store.save()\n    set({ ignoredVersion: version })\n  },\n  \n  checkForUpdates: async () => {\n    try {\n      const update = await check({\n        headers: {\n          'X-AccessKey': 'wHi8Tkuc5i6v1UCAuVk48A',\n        },\n        timeout: 5000,\n      })\n      \n      if (update) {\n        const { ignoredVersion } = get()\n        set({ \n          update,\n          latestVersion: update.version,\n          hasUpdate: update.version !== ignoredVersion\n        })\n      } else {\n        set({ \n          update: null,\n          hasUpdate: false\n        })\n      }\n    } catch {\n      // 检查更新失败，忽略错误\n    }\n  },\n  \n  ignoreCurrentVersion: async () => {\n    const { latestVersion } = get()\n    if (latestVersion) {\n      await get().setIgnoredVersion(latestVersion)\n      set({ hasUpdate: false })\n    }\n  },\n  \n  initUpdateStore: async () => {\n    const store = await Store.load('store.json')\n    const ignoredVersion = await store.get('ignoredVersion') as string\n    if (ignoredVersion) {\n      set({ ignoredVersion })\n    }\n  }\n}))\n\nexport default useUpdateStore\n"
  },
  {
    "path": "src/stores/vector.ts",
    "content": "import { create } from 'zustand';\nimport { initVectorDb, processAllMarkdownFiles, processMarkdownFile, checkEmbeddingModelAvailable, initBM25Search } from '@/lib/rag';\nimport { checkRerankModelAvailable } from '@/lib/ai/embedding';\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { toast } from '@/hooks/use-toast';\n\ninterface VectorState {\n  isRagEnabled: boolean;           // 是否启用RAG检索功能\n  isProcessing: boolean;           // 是否正在处理向量\n  lastProcessTime: number | null;  // 最后一次处理向量的时间\n  hasRerankModel: boolean;         // 是否有可用的重排序模型\n\n  // 统计数据\n  documentCount: number;           // 文档数量\n\n  // 初始化函数\n  initVectorDb: () => Promise<void>;\n\n  // RAG启用/禁用\n  setRagEnabled: (enabled: boolean) => Promise<void>;\n\n  // 处理向量\n  processAllDocuments: () => Promise<void>;\n  processDocument: (filename: string, content: string) => Promise<void>;\n  checkEmbeddingModel: () => Promise<boolean>;\n  checkRerankModel: () => Promise<boolean>;\n}\n\nconst useVectorStore = create<VectorState>((set, get) => ({\n  isRagEnabled: false,\n  isProcessing: false,\n  lastProcessTime: null,\n  hasRerankModel: false,\n  documentCount: 0,\n\n  // 初始化向量数据库\n  initVectorDb: async () => {\n    try {\n      await initVectorDb();\n\n      // 初始化 BM25 索引\n      await initBM25Search();\n\n      // 读取用户设置\n      const store = await Store.load('store.json');\n      const isRagEnabled = await store.get<boolean>('isRagEnabled') || false;\n      const lastProcessTime = await store.get<number>('lastVectorProcessTime') || null;\n\n      set({\n        isRagEnabled,\n        lastProcessTime\n      });\n\n      // 检查嵌入模型可用性\n      const modelAvailable = await get().checkEmbeddingModel();\n      if (!modelAvailable) {\n        toast({\n          title: '向量数据库',\n          description: '未配置嵌入模型或模型不可用，请在AI设置中配置嵌入模型',\n          variant: 'destructive',\n        });\n      }\n\n      // 检查重排序模型是否可用\n      const hasRerankModel = await get().checkRerankModel();\n      set({ hasRerankModel });\n    } catch (error) {\n      console.error('初始化向量数据库失败:', error);\n    }\n  },\n\n  // 设置RAG启用状态\n  setRagEnabled: async (enabled: boolean) => {\n    try {\n      const store = await Store.load('store.json');\n      await store.set('isRagEnabled', enabled);\n\n      set({ isRagEnabled: enabled });\n    } catch (error) {\n      console.error('设置RAG状态失败:', error);\n    }\n  },\n\n  // 处理所有文档向量\n  processAllDocuments: async () => {\n    // 如果已经在处理中，直接返回\n    if (get().isProcessing) return;\n\n    try {\n      // 检查嵌入模型是否可用\n      const modelAvailable = await get().checkEmbeddingModel();\n      if (!modelAvailable) {\n        toast({\n          title: '向量处理',\n          description: '未配置嵌入模型或模型不可用，请在AI设置中配置嵌入模型',\n          variant: 'destructive',\n        });\n        return;\n      }\n\n      // 设置处理状态\n      set({ isProcessing: true });\n\n      // 显示处理开始的提示\n      toast({\n        title: '向量处理',\n        description: '开始处理文档向量，这可能需要一些时间...',\n      });\n\n      // 处理所有文档，带进度回调\n      const result = await processAllMarkdownFiles((current, total, fileName) => {\n        // 更新进度提示（只显示关键节点）\n        if (current === 1 || current === total || current % 5 === 0) {\n          toast({\n            title: '向量处理中',\n            description: `正在处理 ${fileName} (${current}/${total})`,\n          });\n        }\n      });\n\n      // 更新处理时间和状态\n      const currentTime = Date.now();\n      const store = await Store.load('store.json');\n      await store.set('lastVectorProcessTime', currentTime);\n\n      set({\n        isProcessing: false,\n        lastProcessTime: currentTime,\n        documentCount: result.success\n      });\n\n      // 重新初始化 BM25 索引\n      await initBM25Search();\n\n      // 显示处理结果\n      let description = `成功处理 ${result.success} 个文档`;\n      if (result.failed > 0) {\n        description += `，失败 ${result.failed} 个文档`;\n        // 如果有失败文件，显示前几个\n        if (result.failedFiles && result.failedFiles.length > 0) {\n          const failedSample = result.failedFiles.slice(0, 3).map(f => f.fileName).join('、');\n          description += `\\n失败文件: ${failedSample}${result.failedFiles.length > 3 ? ' 等' : ''}`;\n        }\n      }\n\n      toast({\n        title: result.failed > 0 ? '向量处理完成（部分失败）' : '向量处理完成',\n        description,\n        variant: result.failed > 0 ? 'destructive' : 'default',\n      });\n    } catch (error) {\n      console.error('处理文档向量失败:', error);\n      set({ isProcessing: false });\n\n      toast({\n        title: '向量处理失败',\n        description: '处理文档向量时发生错误，请查看控制台日志',\n        variant: 'destructive',\n      });\n    }\n  },\n\n  // 处理单个文档向量\n  processDocument: async (filePath: string, content: string) => {\n    try {\n      await processMarkdownFile(filePath, content);\n    } catch (error) {\n      console.error(`处理文档 ${filePath} 向量失败:`, error);\n    }\n  },\n\n  // 检查嵌入模型可用性\n  checkEmbeddingModel: async () => {\n    try {\n      const modelAvailable = await checkEmbeddingModelAvailable();\n      return modelAvailable;\n    } catch (error) {\n      console.error('检查嵌入模型失败:', error);\n      return false;\n    }\n  },\n\n  // 检查重排序模型可用性\n  checkRerankModel: async () => {\n    try {\n      const modelAvailable = await checkRerankModelAvailable();\n      set({ hasRerankModel: modelAvailable });\n      return modelAvailable;\n    } catch (error) {\n      console.error('检查重排序模型失败:', error);\n      set({ hasRerankModel: false });\n      return false;\n    }\n  }\n}));\n\nexport default useVectorStore;\n"
  },
  {
    "path": "src/types/sync.ts",
    "content": "export type SyncPlatform = 'github' | 'gitee' | 'gitlab' | 'gitea' | 's3' | 'webdav'\n\nexport type SyncPlatformType = {\n  platform: SyncPlatform\n  name: string\n  icon: string\n}\n\nexport const SYNC_PLATFORMS: SyncPlatform[] = ['github', 'gitee', 'gitlab', 'gitea', 's3', 'webdav']\n\nexport const SYNC_PLATFORM_INFO: Record<SyncPlatform, SyncPlatformType> = {\n  github: { platform: 'github', name: 'Github', icon: 'github' },\n  gitee: { platform: 'gitee', name: 'Gitee', icon: 'gitee' },\n  gitlab: { platform: 'gitlab', name: 'GitLab', icon: 'gitlab' },\n  gitea: { platform: 'gitea', name: 'Gitea', icon: 'gitea' },\n  s3: { platform: 's3', name: 'S3', icon: 's3' },\n  webdav: { platform: 'webdav', name: 'WebDAV', icon: 'webdav' },\n}\n\nexport interface S3Config {\n  accessKeyId: string\n  secretAccessKey: string\n  region: string\n  bucket: string\n  endpoint: string\n  pathPrefix: string\n  customDomain?: string\n}\n\nexport interface WebDAVConfig {\n  url: string\n  username: string\n  password: string\n  pathPrefix: string\n}\n"
  },
  {
    "path": "src/types/theme.ts",
    "content": "/**\n * 自定义主题颜色配置\n * 使用 HSL 格式，值为 [hue, saturation, lightness] 数组或 null\n * null 表示使用默认值\n */\nexport interface CustomThemeColors {\n  // 亮色主题颜色\n  light: {\n    background: HSLValue | null\n    foreground: HSLValue | null\n    card: HSLValue | null\n    cardForeground: HSLValue | null\n    primary: HSLValue | null\n    primaryForeground: HSLValue | null\n    secondary: HSLValue | null\n    secondaryForeground: HSLValue | null\n    third: HSLValue | null\n    thirdForeground: HSLValue | null\n    muted: HSLValue | null\n    mutedForeground: HSLValue | null\n    accent: HSLValue | null\n    accentForeground: HSLValue | null\n    border: HSLValue | null\n    shadow: HSLValue | null\n  }\n  // 暗色主题颜色\n  dark: {\n    background: HSLValue | null\n    foreground: HSLValue | null\n    card: HSLValue | null\n    cardForeground: HSLValue | null\n    primary: HSLValue | null\n    primaryForeground: HSLValue | null\n    secondary: HSLValue | null\n    secondaryForeground: HSLValue | null\n    third: HSLValue | null\n    thirdForeground: HSLValue | null\n    muted: HSLValue | null\n    mutedForeground: HSLValue | null\n    accent: HSLValue | null\n    accentForeground: HSLValue | null\n    border: HSLValue | null\n    shadow: HSLValue | null\n  }\n}\n\n/**\n * HSL 颜色值\n */\nexport type HSLValue = [number, number, number]\n\n/**\n * 获取主题 CSS 变量名映射\n */\nexport const THEME_VARIABLE_MAP = {\n  light: {\n    background: '--background',\n    foreground: '--foreground',\n    card: '--card',\n    cardForeground: '--card-foreground',\n    primary: '--primary',\n    primaryForeground: '--primary-foreground',\n    secondary: '--secondary',\n    secondaryForeground: '--secondary-foreground',\n    third: '--third',\n    thirdForeground: '--third-foreground',\n    muted: '--muted',\n    mutedForeground: '--muted-foreground',\n    accent: '--accent',\n    accentForeground: '--accent-foreground',\n    border: '--border',\n    shadow: '--shadow',\n  },\n  dark: {\n    background: '--background',\n    foreground: '--foreground',\n    card: '--card',\n    cardForeground: '--card-foreground',\n    primary: '--primary',\n    primaryForeground: '--primary-foreground',\n    secondary: '--secondary',\n    secondaryForeground: '--secondary-foreground',\n    third: '--third',\n    thirdForeground: '--third-foreground',\n    muted: '--muted',\n    mutedForeground: '--muted-foreground',\n    accent: '--accent',\n    accentForeground: '--accent-foreground',\n    border: '--border',\n    shadow: '--shadow',\n  },\n} as const\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n# Generated by Tauri\n# will have schema files for capabilities auto-completion\n/gen/schemas\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"note-gen\"\nversion = \"0.1.0\"\ndescription = \"A Tauri App\"\nauthors = [\"codexu\"]\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\n# The `_lib` suffix may seem redundant but it is necessary\n# to make the lib name unique and wouldn't conflict with the bin name.\n# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519\nname = \"tauri_app_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\n\n[dependencies]\ntauri = { version = \"2\", features = [ \"macos-private-api\", \"protocol-asset\", \"image-png\", \"devtools\", \"tray-icon\" ] }\ntauri-plugin-shell = \"2\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"=1\"\ntauri-plugin-store = \"2\"\ntauri-plugin-fs = \"2\"\ntauri-plugin-dialog = \"2\"\ntauri-plugin-http = { version = \"=2.3.0\", features = [\"unsafe-headers\"] }\ntauri-plugin-os = \"2\"\ntauri-plugin-single-instance = \"2\"\nurlencoding = \"2.1.3\"\npercent-encoding = \"2.3.0\"\nfuzzy-matcher = \"0.3.7\"\nrayon = \"1.8.0\"\ntokio = { version = \"1\", features = [\"full\"] }\nurl = \"2.5\"\nreqwest_dav = \"=0.2.1\"\ntauri-plugin-opener = \"2.4.0\"\nuuid = { version = \"1.10\", features = [\"v4\"] }\nzip = { version = \"4\", default-features = false, features = [\"deflate\"] }\n\n[dependencies.tauri-plugin-sql]\nfeatures = [\"sqlite\"]\nversion = \"2\"\n\n[target.'cfg(not(any(target_os = \"android\", target_os = \"ios\")))'.dependencies]\ntauri-plugin-global-shortcut = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-window-state = \"2\"\ntauri-plugin-clipboard = \"2\"\nxcap = \"=0.6.0\"\nreqwest_dav = \"=0.2.1\"\ntauri-plugin-process = \"=2.0.0\"\njieba-rs = { version = \"0.7\", features = [\"textrank\"] }\nmachine-uid = \"0.5\"\nregex = \"1\"\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nwindows = { version = \"0.58\", features = [\"Win32_Foundation\", \"Win32_System_Threading\"] }\n\n[target.'cfg(target_os = \"android\")'.dependencies]\nopenssl = { version = \"0.10\", features = [\"vendored\"] }\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\ncore-graphics = \"0.23\"\n\n[target.'cfg(not(any(target_os = \"android\", target_os = \"ios\")))'.dependencies.tauri]\nversion = \"2\"\nfeatures = []\n"
  },
  {
    "path": "src-tauri/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>NSAppTransportSecurity</key>\n    <dict>\n        <key>NSAllowsArbitraryLoads</key>\n        <true/>\n    </dict>\n    <key>NSMicrophoneUsageDescription</key>\n    <string>NoteGen 需要访问麦克风以支持语音录音和语音转文字功能。</string>\n    <key>NSPhotoLibraryUsageDescription</key>\n    <string>NoteGen 需要访问相册以支持图片上传和图片记录功能。</string>\n    <key>NSPhotoLibraryAddUsageDescription</key>\n    <string>NoteGen 需要保存图片到相册。</string>\n    <key>NSCameraUsageDescription</key>\n    <string>NoteGen 需要访问相机以支持拍照和图片记录功能。</string>\n    <key>UIFileSharingEnabled</key>\n    <true/>\n    <key>LSSupportsOpeningDocumentsInPlace</key>\n    <true/>\n    </dict>\n</plist>"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Capability for the main window\",\n  \"windows\": [\n    \"main\",\n    \"screenshot\"\n  ],\n  \"permissions\": [\n    \"core:default\",\n    \"shell:allow-open\",\n    \"sql:default\",\n    \"sql:allow-load\",\n    \"sql:allow-execute\",\n    \"sql:allow-select\",\n    \"sql:allow-close\",\n    \"store:default\",\n    \"store:allow-get\",\n    \"store:allow-set\",\n    \"store:allow-save\",\n    \"store:allow-load\",\n    \"fs:default\",\n    {\n      \"identifier\": \"fs:scope\",\n      \"allow\": [\n        {\n          \"path\": \"$APPDATA\"\n        },\n        {\n          \"path\": \"$APPDATA/**\"\n        },\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    {\n      \"identifier\": \"fs:read-all\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    \"fs:allow-exists\",\n    {\n      \"identifier\": \"fs:allow-mkdir\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    {\n      \"identifier\": \"fs:write-files\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    {\n      \"identifier\": \"fs:read-dirs\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    {\n      \"identifier\": \"fs:read-files\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    \"fs:write-all\",\n    \"core:webview:default\",\n    \"core:webview:allow-create-webview-window\",\n    \"core:webview:allow-webview-show\",\n    \"core:webview:allow-webview-hide\",\n    \"core:window:default\",\n    \"core:window:allow-close\",\n    \"core:window:allow-maximize\",\n    \"core:window:allow-minimize\",\n    \"core:window:allow-unmaximize\",\n    \"core:window:allow-set-focus\",\n    \"core:window:allow-set-size\",\n    \"core:window:allow-set-position\",\n    \"core:window:allow-hide\",\n    \"core:window:allow-show\",\n    \"core:window:allow-destroy\",\n    \"core:path:allow-resolve-directory\",\n    \"shell:default\",\n    {\n      \"identifier\": \"shell:allow-execute\",\n      \"allow\": [\n        {\n          \"name\": \"bash\",\n          \"cmd\": \"bash\",\n          \"sidecar\": false,\n          \"args\": true\n        },\n        {\n          \"name\": \"python\",\n          \"cmd\": \"python\",\n          \"sidecar\": false,\n          \"args\": true\n        },\n        {\n          \"name\": \"python3\",\n          \"cmd\": \"python3\",\n          \"sidecar\": false,\n          \"args\": true\n        }\n      ]\n    }\n    ,\n    \"core:app:allow-default-window-icon\",\n    \"core:window:allow-unminimize\",\n    \"core:window:allow-start-dragging\",\n    \"core:window:allow-toggle-maximize\",\n    {\n      \"identifier\": \"http:allow-fetch\",\n      \"allow\": [\n        {\n          \"url\": \"http://**\"\n        },\n        {\n          \"url\": \"https://**\"\n        },\n        {\n          \"url\": \"http://*:*\"\n        },\n        {\n          \"url\": \"https://*:*\"\n        }\n      ]\n    },\n    \"os:default\",\n    \"dialog:default\",\n    \"http:default\",\n    {\n      \"identifier\": \"opener:allow-open-path\",\n      \"allow\": [\n        {\n          \"path\": \"**\"\n        }\n      ]\n    },\n    \"opener:default\",\n    \"opener:allow-open-url\"\n  ]\n}"
  },
  {
    "path": "src-tauri/capabilities/desktop.json",
    "content": "{\n  \"identifier\": \"desktop-capability\",\n  \"platforms\": [\n    \"macOS\",\n    \"windows\",\n    \"linux\"\n  ],\n  \"windows\": [\n    \"main\"\n  ],\n  \"permissions\": [\n    \"global-shortcut:default\",\n    \"global-shortcut:allow-register\",\n    \"global-shortcut:allow-register-all\",\n    \"global-shortcut:allow-is-registered\",\n    \"global-shortcut:allow-unregister\",\n    \"global-shortcut:allow-unregister-all\",\n    \"window-state:default\",\n    \"os:default\",\n    \"core:window:allow-set-always-on-top\",\n    \"core:window:allow-start-dragging\",\n    \"clipboard:allow-read-image-base64\",\n    \"clipboard:read-all\",\n    \"clipboard:allow-clear\",\n    \"clipboard:allow-write-text\",\n    \"clipboard:allow-start-monitor\",\n    \"updater:default\",\n    \"process:default\",\n    \"global-shortcut:allow-is-registered\",\n    \"global-shortcut:allow-register\",\n    \"global-shortcut:allow-unregister\",\n    \"core:window:allow-unminimize\",\n    \"os:default\",\n    \"shell:default\",\n    {\n      \"identifier\": \"shell:allow-execute\",\n      \"allow\": [\n        {\n          \"name\": \"bash\",\n          \"cmd\": \"bash\",\n          \"sidecar\": false,\n          \"args\": true\n        },\n        {\n          \"name\": \"python\",\n          \"cmd\": \"python\",\n          \"sidecar\": false,\n          \"args\": true\n        },\n        {\n          \"name\": \"python3\",\n          \"cmd\": \"python3\",\n          \"sidecar\": false,\n          \"args\": true\n        }\n      ]\n    },\n    \"opener:default\",\n    \"opener:allow-open-url\",\n    \"opener:allow-open-path\"\n  ]\n}"
  },
  {
    "path": "src-tauri/src/app_setup.rs",
    "content": "use tauri::App;\n#[cfg(target_os = \"windows\")]\nuse tauri::Manager;\nuse crate::window;\nuse crate::tray::create_tray;\n\npub fn setup_app(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {\n    let app_handle = app.handle();\n\n    // 在 Windows 上明确禁用窗口装饰\n    #[cfg(target_os = \"windows\")]\n    {\n        if let Some(window) = app_handle.get_webview_window(\"main\") {\n            let _ = window.set_decorations(false);\n            let _ = window.set_title(\"NoteGen\");\n        }\n    }\n\n    // 设置窗口事件监听器\n    window::setup_window_events(&app_handle)?;\n\n    // 创建系统托盘\n    let _tray = create_tray(&app_handle)?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/backup.rs",
    "content": "use std::fs;\nuse std::io::{Read, Seek, Write};\nuse std::path::{Path, PathBuf};\n\nuse tauri::{AppHandle, Manager, command};\n\nuse zip::write::SimpleFileOptions;\nuse zip::CompressionMethod;\nuse zip::ZipArchive;\nuse zip::ZipWriter;\n\n#[command]\npub async fn import_app_data_from_file(\n    app_handle: AppHandle,\n    _file_name: String,\n    file_content: Vec<u8>,\n) -> Result<(), String> {\n    let data_dir = app_handle\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app_data_dir: {}\", e))?;\n\n    // 将文件内容保存到临时文件\n    let temp_zip_path = data_dir.join(\"temp_import.zip\");\n    fs::write(&temp_zip_path, &file_content)\n        .map_err(|e| format!(\"Failed to write temp file: {}\", e))?;\n\n    // 创建临时目录用于解压\n    let temp_dir = data_dir.join(\"temp_import\");\n    if temp_dir.exists() {\n        fs::remove_dir_all(&temp_dir)\n            .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n    }\n    fs::create_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to create temp directory: {}\", e))?;\n\n    // 使用 zip crate 解压\n    extract_zip(temp_zip_path.as_path(), &temp_dir)?;\n\n    // 处理 store.json\n    let store_path = temp_dir.join(\"store.json\");\n    if store_path.exists() {\n        let dest_store_path = data_dir.join(\"store.json\");\n        fs::copy(&store_path, &dest_store_path)\n            .map_err(|e| format!(\"Failed to copy store.json: {}\", e))?;\n    }\n\n    // 复制其他文件\n    for entry in fs::read_dir(&temp_dir)\n        .map_err(|e| format!(\"Failed to read temp directory: {}\", e))? {\n        let entry = entry.map_err(|e| format!(\"Failed to read directory entry: {}\", e))?;\n        let file_name = entry.file_name();\n\n        if file_name == \"store.json\" {\n            continue;\n        }\n\n        // 跳过 SQLite 临时文件，让数据库重新创建\n        let file_name_str = file_name.to_string_lossy();\n        if file_name_str.ends_with(\".db-shm\") || file_name_str.ends_with(\".db-wal\") {\n            continue;\n        }\n\n        let src_path = entry.path();\n        let dest_path = data_dir.join(&file_name);\n\n        if src_path.is_file() {\n            fs::copy(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy file {}: {}\", file_name.to_string_lossy(), e))?;\n        } else if src_path.is_dir() {\n            copy_dir_recursive(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy directory {}: {}\", file_name.to_string_lossy(), e))?;\n        }\n    }\n\n    // 清理临时目录\n    fs::remove_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n    fs::remove_file(&temp_zip_path)\n        .map_err(|e| format!(\"Failed to remove temp zip file: {}\", e))?;\n\n    Ok(())\n}\n\n#[command]\npub async fn export_app_data(app_handle: AppHandle, output_path: String) -> Result<String, String> {\n    // 获取数据目录\n    let data_dir = app_handle\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app_data_dir: {}\", e))?;\n\n    if !data_dir.exists() {\n        return Err(format!(\"Data directory does not exist: {:?}\", data_dir));\n    }\n\n    // 尝试直接保存到用户选择的路径\n    let dest_path = PathBuf::from(&output_path);\n\n    // 尝试压缩\n    let write_result = compress_dir(&data_dir, &dest_path);\n\n    match write_result {\n        Ok(_) => Ok(dest_path.to_string_lossy().to_string()),\n        Err(_e) => {\n            // 如果失败，尝试保存到 document_dir\n            let export_dir = app_handle\n                .path()\n                .document_dir()\n                .map_err(|e| format!(\"Failed to get document_dir: {}\", e))?;\n\n            let file_name = PathBuf::from(&output_path)\n                .file_name()\n                .map(|n| n.to_string_lossy().to_string())\n                .unwrap_or_else(|| \"note-gen-backup.zip\".to_string());\n\n            let new_dest_path = export_dir.join(&file_name);\n\n            compress_dir(&data_dir, &new_dest_path)?;\n\n            Ok(new_dest_path.to_string_lossy().to_string())\n        }\n    }\n}\n\n#[command]\npub async fn import_app_data(app_handle: AppHandle, zip_path: String) -> Result<(), String> {\n    let data_dir = app_handle\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app_data_dir: {}\", e))?;\n\n    // 创建临时目录用于解压\n    let temp_dir = data_dir.join(\"temp_import\");\n    if temp_dir.exists() {\n        fs::remove_dir_all(&temp_dir)\n            .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n    }\n    fs::create_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to create temp directory: {}\", e))?;\n\n    // 使用 zip crate 解压\n    extract_zip(PathBuf::from(&zip_path).as_path(), &temp_dir)?;\n\n    // 处理 store.json\n    let store_path = temp_dir.join(\"store.json\");\n    if store_path.exists() {\n        let dest_store_path = data_dir.join(\"store.json\");\n        fs::copy(&store_path, &dest_store_path)\n            .map_err(|e| format!(\"Failed to copy store.json: {}\", e))?;\n    }\n\n    // 复制其他文件\n    for entry in fs::read_dir(&temp_dir)\n        .map_err(|e| format!(\"Failed to read temp directory: {}\", e))? {\n        let entry = entry.map_err(|e| format!(\"Failed to read directory entry: {}\", e))?;\n        let file_name = entry.file_name();\n\n        if file_name == \"store.json\" {\n            continue;\n        }\n\n        // 跳过 SQLite 临时文件，让数据库重新创建\n        let file_name_str = file_name.to_string_lossy();\n        if file_name_str.ends_with(\".db-shm\") || file_name_str.ends_with(\".db-wal\") {\n            continue;\n        }\n\n        let src_path = entry.path();\n        let dest_path = data_dir.join(&file_name);\n\n        if src_path.is_file() {\n            fs::copy(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy file {}: {}\", file_name.to_string_lossy(), e))?;\n        } else if src_path.is_dir() {\n            copy_dir_recursive(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy directory {}: {}\", file_name.to_string_lossy(), e))?;\n        }\n    }\n\n    // 清理临时目录\n    fs::remove_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n\n    Ok(())\n}\n\n// 递归复制目录的辅助函数\nfn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {\n    if !dest.exists() {\n        fs::create_dir_all(dest).map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n\n    for entry in fs::read_dir(src).map_err(|e| format!(\"Failed to read directory: {}\", e))? {\n        let entry = entry.map_err(|e| format!(\"Failed to read entry: {}\", e))?;\n        let src_path = entry.path();\n        let dest_path = dest.join(entry.file_name());\n\n        if src_path.is_file() {\n            fs::copy(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy file: {}\", e))?;\n        } else if src_path.is_dir() {\n            copy_dir_recursive(&src_path, &dest_path)?;\n        }\n    }\n\n    Ok(())\n}\n\n// 使用 zip crate 压缩目录\nfn compress_dir(src_dir: &Path, dest_file: &Path) -> Result<(), String> {\n    // 确保父目录存在\n    if let Some(parent) = dest_file.parent() {\n        if parent != src_dir {\n            fs::create_dir_all(parent).map_err(|e| format!(\"Failed to create parent directory: {}\", e))?;\n        }\n    }\n\n    let file = fs::File::create(dest_file)\n        .map_err(|e| format!(\"Failed to create zip file: {}\", e))?;\n\n    let mut zip = ZipWriter::new(file);\n    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);\n\n    let base_path = src_dir.to_path_buf();\n    add_dir_to_zip(&mut zip, &base_path, &base_path, &options)?;\n\n    zip.finish().map_err(|e| format!(\"Failed to finish zip: {}\", e))?;\n\n    Ok(())\n}\n\nfn add_dir_to_zip<W: Write + Seek>(\n    zip: &mut ZipWriter<W>,\n    base_path: &Path,\n    current_path: &Path,\n    options: &SimpleFileOptions,\n) -> Result<(), String> {\n    if !current_path.exists() {\n        return Ok(());\n    }\n\n    for entry in fs::read_dir(current_path)\n        .map_err(|e| format!(\"Failed to read directory: {}\", e))?\n    {\n        let entry = entry.map_err(|e| format!(\"Failed to read entry: {}\", e))?;\n        let path = entry.path();\n        let relative_path = path\n            .strip_prefix(base_path)\n            .map_err(|e| format!(\"Failed to get relative path: {}\", e))?;\n\n        if path.is_file() {\n            let file_name = relative_path.to_string_lossy();\n            zip.start_file(file_name, *options)\n                .map_err(|e| format!(\"Failed to start file in zip: {}\", e))?;\n\n            let mut file = fs::File::open(&path)\n                .map_err(|e| format!(\"Failed to open file: {}\", e))?;\n            let mut buffer = Vec::new();\n            file.read_to_end(&mut buffer)\n                .map_err(|e| format!(\"Failed to read file: {}\", e))?;\n            zip.write_all(&buffer)\n                .map_err(|e| format!(\"Failed to write file to zip: {}\", e))?;\n        } else if path.is_dir() {\n            let dir_name = format!(\"{}/\", relative_path.to_string_lossy());\n            zip.add_directory(&dir_name, *options)\n                .map_err(|e| format!(\"Failed to add directory to zip: {}\", e))?;\n            add_dir_to_zip(zip, base_path, &path, options)?;\n        }\n    }\n\n    Ok(())\n}\n\n// 解压 zip 文件\nfn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> {\n    let file = fs::File::open(zip_path)\n        .map_err(|e| format!(\"Failed to open zip file: {}\", e))?;\n    let mut archive = ZipArchive::new(file)\n        .map_err(|e| format!(\"Failed to read zip archive: {}\", e))?;\n\n    for i in 0..archive.len() {\n        let mut file = archive.by_index(i)\n            .map_err(|e| format!(\"Failed to read file from zip: {}\", e))?;\n\n        let outpath = match file.enclosed_name() {\n            Some(path) => dest_dir.join(path),\n            None => continue,\n        };\n\n        if file.name().ends_with('/') {\n            fs::create_dir_all(&outpath)\n                .map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n        } else {\n            if let Some(parent) = outpath.parent() {\n                if !parent.exists() {\n                    fs::create_dir_all(parent)\n                        .map_err(|e| format!(\"Failed to create parent directory: {}\", e))?;\n                }\n            }\n            let mut outfile = fs::File::create(&outpath)\n                .map_err(|e| format!(\"Failed to create file: {}\", e))?;\n            std::io::copy(&mut file, &mut outfile)\n                .map_err(|e| format!(\"Failed to extract file: {}\", e))?;\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/device.rs",
    "content": "use tauri::command;\n\n/// 获取设备唯一标识 - 桌面端实现\n#[cfg(not(any(target_os = \"android\", target_os = \"ios\")))]\n#[command]\npub fn get_device_id() -> Result<String, String> {\n    machine_uid::get()\n        .map_err(|e| format!(\"Failed to get device ID: {}\", e))\n}\n\n/// 获取设备唯一标识 - Android 实现\n#[cfg(target_os = \"android\")]\n#[command]\npub fn get_device_id() -> Result<String, String> {\n    get_or_create_device_id()\n}\n\n/// 获取设备唯一标识 - iOS 实现\n#[cfg(target_os = \"ios\")]\n#[command]\npub fn get_device_id() -> Result<String, String> {\n    get_or_create_device_id()\n}\n\n/// 移动端通用的设备 ID 获取逻辑\n#[cfg(any(target_os = \"android\", target_os = \"ios\"))]\nfn get_or_create_device_id() -> Result<String, String> {\n    // 使用更简单的方法：直接使用临时目录\n    // 这样可以确保跨平台兼容性和权限访问\n    let app_data_dir = std::env::temp_dir();\n    \n    let device_id_file = app_data_dir.join(\"note-gen-device-id.txt\");\n    \n    // 如果文件存在，读取已有的 ID\n    if device_id_file.exists() {\n        if let Ok(id) = std::fs::read_to_string(&device_id_file) {\n            let trimmed = id.trim();\n            if !trimmed.is_empty() {\n                return Ok(trimmed.to_string());\n            }\n        }\n    }\n    \n    // 如果文件不存在或读取失败，生成新的 UUID\n    let new_id = uuid::Uuid::new_v4().to_string();\n    \n    // 确保目录存在（cache_dir 通常已存在，但为了安全还是检查一下）\n    if let Some(parent) = device_id_file.parent() {\n        std::fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n    \n    // 保存到文件\n    std::fs::write(&device_id_file, &new_id)\n        .map_err(|e| format!(\"Failed to write device ID: {}\", e))?;\n    \n    Ok(new_id)\n}\n"
  },
  {
    "path": "src-tauri/src/fuzzy_search.rs",
    "content": "use fuzzy_matcher::FuzzyMatcher;\nuse fuzzy_matcher::skim::SkimMatcherV2;\nuse rayon::prelude::*;\nuse serde::{Deserialize, Serialize};\nuse std::cmp::Reverse;\nuse tauri::command;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct SearchItem {\n    pub id: Option<String>,\n    pub desc: Option<String>,\n    pub title: Option<String>,\n    pub article: Option<String>,\n    pub url: Option<String>,\n    pub path: Option<String>,\n    pub search_type: Option<String>,\n    pub score: Option<i64>,\n    pub matches: Option<Vec<MatchInfo>>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct MatchInfo {\n    pub key: String,\n    pub indices: Vec<[usize; 2]>,\n    pub value: String,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct FuzzySearchResult {\n    pub item: SearchItem,\n    pub refindex: usize,\n    pub score: i64,\n    pub matches: Vec<MatchInfo>,\n}\n\nfn search_item(\n    item: &SearchItem,\n    pattern: &str,\n    keys: &[&str],\n    threshold: f64,\n) -> Option<FuzzySearchResult> {\n    let matcher = SkimMatcherV2::default();\n    let mut best_score = 0;\n    let mut all_matches = Vec::new();\n    let mut has_match = false;\n    \n    for key in keys {\n        let text = match *key {\n            \"desc\" => item.desc.as_deref().unwrap_or(\"\"),\n            \"title\" => item.title.as_deref().unwrap_or(\"\"),\n            \"article\" => item.article.as_deref().unwrap_or(\"\"),\n            \"path\" => item.path.as_deref().unwrap_or(\"\"),\n            \"search_type\" => item.search_type.as_deref().unwrap_or(\"\"),\n            _ => continue,\n        };\n        \n        if let Some((score, indices)) = matcher.fuzzy_indices(text, pattern) {\n            let normalized_score = (score as f64).abs() / (pattern.len() as f64);\n            \n            if normalized_score < threshold {\n                continue;\n            }\n            \n            has_match = true;\n            \n            if score > best_score {\n                best_score = score;\n            }\n            \n            let mut ranges = Vec::new();\n            for &idx in &indices {\n                ranges.push([idx, idx]);\n            }\n            \n            all_matches.push(MatchInfo {\n                key: key.to_string(),\n                indices: ranges,\n                value: text.to_string(),\n            });\n        }\n    }\n    \n    if !has_match {\n        return None;\n    }\n    \n    Some(FuzzySearchResult {\n        item: item.clone(),\n        refindex: 0,\n        score: best_score,\n        matches: all_matches,\n    })\n}\n\n#[command]\npub fn fuzzy_search(\n    items: Vec<SearchItem>,\n    query: String,\n    keys: Vec<String>,\n    threshold: f64,\n    include_score: bool,\n    include_matches: bool,\n) -> Vec<FuzzySearchResult> {\n    if query.is_empty() {\n        return Vec::new();\n    }\n    \n    let keys_str: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();\n    \n    let mut results: Vec<_> = items\n        .par_iter()\n        .enumerate()\n        .filter_map(|(index, item)| {\n            let mut result = search_item(item, &query, &keys_str, threshold)?;\n            result.refindex = index;\n            Some(result)\n        })\n        .collect();\n    \n    results.sort_by_key(|r| Reverse(r.score));\n    \n    if !include_score || !include_matches {\n        for result in &mut results {\n            if !include_score {\n                result.score = 0;\n                result.item.score = None;\n            }\n            if !include_matches {\n                result.matches.clear();\n                result.item.matches = None;\n            }\n        }\n    }\n    \n    results\n}\n\n#[command]\npub fn fuzzy_search_parallel(\n    items: Vec<SearchItem>,\n    query: String,\n    keys: Vec<String>,\n    threshold: f64,\n    include_score: bool,\n    include_matches: bool,\n) -> Vec<FuzzySearchResult> {\n    fuzzy_search(items, query, keys, threshold, include_score, include_matches)\n}\n"
  },
  {
    "path": "src-tauri/src/keywords.rs",
    "content": "use jieba_rs::Jieba;\nuse jieba_rs::KeywordExtract;\nuse jieba_rs::TextRank;\nuse serde::{Serialize, Deserialize};\nuse std::sync::OnceLock;\nuse tauri::command;\nuse std::collections::HashSet;\nuse regex::Regex;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct Keyword {\n    pub text: String,\n    pub weight: f64,\n}\n\nfn get_jieba() -> &'static Jieba {\n    static JIEBA: OnceLock<Jieba> = OnceLock::new();\n\n    JIEBA.get_or_init(|| {\n        Jieba::new()\n    })\n}\n\nfn get_text_rank() -> TextRank {\n    TextRank::default()\n}\n\n/// 获取停用词集合\n/// 过滤掉没有实际检索意义的虚词、系动词等\nfn get_stop_words() -> HashSet<&'static str> {\n    [\n        // 中文虚词/系动词\n        \"的\", \"了\", \"是\", \"在\", \"有\", \"和\", \"就\", \"不\", \"人\", \"都\", \"一\", \"一个\",\n        \"上\", \"也\", \"很\", \"到\", \"说\", \"要\", \"去\", \"你\", \"会\", \"着\", \"没有\", \"看\",\n        \"好\", \"自己\", \"这\", \"那\", \"里\", \"就是\", \"为\", \"与\", \"之\", \"用\", \"可以\",\n        \"但\", \"而\", \"或\", \"及\", \"等\", \"对\", \"把\", \"被\", \"让\", \"给\", \"从\", \"向\",\n        \"什么\", \"怎么\", \"怎样\", \"如何\", \"为什么\", \"哪些\", \"多少\",\n\n        // 英文停用词\n        \"the\", \"a\", \"an\", \"and\", \"or\", \"but\", \"in\", \"on\", \"at\", \"to\", \"for\",\n        \"of\", \"with\", \"by\", \"from\", \"as\", \"is\", \"was\", \"are\", \"were\", \"been\",\n        \"be\", \"have\", \"has\", \"had\", \"do\", \"does\", \"did\", \"will\", \"would\", \"could\",\n        \"should\", \"may\", \"might\", \"must\", \"can\", \"this\", \"that\", \"these\", \"those\",\n        \"what\", \"how\", \"why\", \"where\", \"when\", \"who\", \"which\",\n    ].into_iter().collect()\n}\n\n/// 检查词是否为停用词\nfn is_stop_word(word: &str) -> bool {\n    let stop_words = get_stop_words();\n    let word_lower = word.to_lowercase();\n    // 检查是否在停用词表中，或者长度为1（单字没有检索意义）\n    stop_words.contains(word_lower.as_str()) || word.len() <= 1\n}\n\n/// 从文本中提取英文单词\n/// 作为 jieba 分词的后备机制，用于提取英文专业术语（如 iPhone、API 等）\nfn extract_english_words(text: &str) -> Vec<String> {\n    // 匹配连续的英文字母（包括大写开头的词）\n    let re = Regex::new(r\"[A-Za-z]{2,}\").unwrap();\n    let mut words = Vec::new();\n\n    for cap in re.find_iter(text) {\n        let word = cap.as_str();\n        // 过滤掉停用词\n        if !is_stop_word(word) {\n            words.push(word.to_string());\n        }\n    }\n\n    // 去重\n    words.sort();\n    words.dedup();\n    words\n}\n\n#[command]\npub fn rank_keywords(text: &str, top_k: usize, allowed_pos: Option<Vec<String>>) -> Vec<Keyword> {\n    let jieba = get_jieba();\n    let extractor = get_text_rank();\n\n    let pos_tags = allowed_pos.unwrap_or_else(||\n        vec![\n            String::from(\"n\"),    // noun\n            String::from(\"ns\"),   // place name\n            String::from(\"nr\"),   // person name\n            String::from(\"nz\"),   // other proper noun\n            String::from(\"v\"),    // verb\n            String::from(\"vn\"),   // verbal noun\n            String::from(\"a\"),    // adjective\n            String::from(\"ad\"),   // adjective as verb\n            String::from(\"an\"),   // adjective as noun\n            String::from(\"eng\"),  // 英文字母（尝试支持英文）\n        ]\n    );\n\n    // 提取更多候选关键词（因为会被过滤掉一部分）\n    let extract_k = top_k * 3;\n    let jieba_keywords = extractor.extract_keywords(\n        jieba,\n        text,\n        extract_k,\n        pos_tags,\n    );\n\n    // 过滤掉停用词\n    let filtered_keywords: Vec<_> = jieba_keywords\n        .into_iter()\n        .filter(|kw| !is_stop_word(&kw.keyword))\n        .collect();\n\n    // 如果 jieba 没有提取到任何关键词，尝试使用英文单词提取作为后备\n    if filtered_keywords.is_empty() {\n        let english_words = extract_english_words(text);\n        if !english_words.is_empty() {\n            return english_words\n                .into_iter()\n                .take(top_k)\n                .map(|word| Keyword {\n                    text: word,\n                    weight: 1000000000.0, // 给英文单词一个高权重\n                })\n                .collect();\n        }\n    }\n\n    // 取前 top_k 个\n    filtered_keywords\n        .into_iter()\n        .take(top_k)\n        .map(|kw| Keyword {\n            text: kw.keyword.clone(),\n            weight: kw.weight,\n        })\n        .collect()\n}\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "mod mcp;\nmod mcp_runtime;\nmod device;\nmod backup;\nmod skills;\n\nuse mcp::{start_mcp_stdio_server, stop_mcp_server, send_mcp_message, McpServerManager};\nuse mcp_runtime::{cancel_mcp_runtime_install, inspect_mcp_runtime, install_mcp_runtime, RuntimeInstallManager};\nuse device::get_device_id;\nuse backup::{export_app_data, import_app_data, import_app_data_from_file};\nuse skills::import_skill_zip;\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    tauri::Builder::default()\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_store::Builder::new().build())\n        .plugin(tauri_plugin_fs::init())\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_http::init())\n        .plugin(tauri_plugin_os::init())\n        .plugin(tauri_plugin_sql::Builder::default().build())\n        .manage(McpServerManager::new())\n        .manage(RuntimeInstallManager::new())\n        .invoke_handler(tauri::generate_handler![\n            start_mcp_stdio_server,\n            stop_mcp_server,\n            send_mcp_message,\n            inspect_mcp_runtime,\n            install_mcp_runtime,\n            cancel_mcp_runtime_install,\n            get_device_id,\n            export_app_data,\n            import_app_data,\n            import_app_data_from_file,\n            import_skill_zip,\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nmod screenshot;\nmod fuzzy_search;\nmod keywords;\nmod window;\nmod app_setup;\nmod backup;\nmod mcp;\nmod mcp_runtime;\nmod device;\nmod skills;\nmod tray;\n\nuse screenshot::{screenshot};\nuse fuzzy_search::{fuzzy_search, fuzzy_search_parallel};\nuse keywords::{rank_keywords};\nuse backup::{export_app_data, import_app_data, import_app_data_from_file};\nuse skills::import_skill_zip;\nuse mcp::{start_mcp_stdio_server, stop_mcp_server, send_mcp_message, McpServerManager};\nuse mcp_runtime::{cancel_mcp_runtime_install, inspect_mcp_runtime, install_mcp_runtime, RuntimeInstallManager};\nuse device::get_device_id;\n\nfn main() {\n    tauri::Builder::default()\n        // 核心插件 - 最先加载\n        .plugin(tauri_plugin_fs::init())\n        .plugin(tauri_plugin_store::Builder::new().build())\n        .plugin(tauri_plugin_sql::Builder::default().build())\n        .plugin(tauri_plugin_single_instance::init(window::handle_single_instance))\n\n        // MCP 服务器管理器\n        .manage(McpServerManager::new())\n        .manage(RuntimeInstallManager::new())\n\n        // 系统级插件\n        .plugin(tauri_plugin_process::init())\n        .plugin(tauri_plugin_os::init())\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_http::init())\n\n        // UI 相关插件\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_window_state::Builder::new().build())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_clipboard::init())\n        .plugin(tauri_plugin_global_shortcut::Builder::new().build())\n\n        // 功能插件\n        .plugin(tauri_plugin_updater::Builder::new().build())\n\n        // 注册命令处理器\n        .invoke_handler(tauri::generate_handler![\n            screenshot,\n            fuzzy_search,\n            fuzzy_search_parallel,\n            rank_keywords,\n            export_app_data,\n            import_app_data,\n            import_app_data_from_file,\n            import_skill_zip,\n            start_mcp_stdio_server,\n            stop_mcp_server,\n            send_mcp_message,\n            inspect_mcp_runtime,\n            install_mcp_runtime,\n            cancel_mcp_runtime_install,\n            get_device_id,\n        ])\n\n        // 应用设置 - 在所有插件和命令注册后\n        .setup(app_setup::setup_app)\n\n        .build(tauri::generate_context!())\n        .expect(\"error while running tauri application\")\n        .run(|_app_handle, event| match event {\n            #[cfg(target_os = \"macos\")]\n            tauri::RunEvent::Reopen { has_visible_windows, .. } => {\n                window::handle_macos_reopen(&_app_handle, has_visible_windows);\n            }\n            _ => {}\n        });\n}\n"
  },
  {
    "path": "src-tauri/src/mcp.rs",
    "content": "use std::collections::HashMap;\nuse std::process::{Child, Command, Stdio};\nuse std::sync::{Arc, Mutex};\nuse std::io::{BufRead, BufReader, Read, Write};\nuse std::path::PathBuf;\nuse tauri::State;\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n/// MCP 服务器进程管理器\npub struct McpServerManager {\n    processes: Mutex<HashMap<String, Arc<Mutex<Child>>>>,\n}\n\nimpl McpServerManager {\n    pub fn new() -> Self {\n        Self {\n            processes: Mutex::new(HashMap::new()),\n        }\n    }\n}\n\nfn encode_mcp_message(message: &str) -> Vec<u8> {\n    format!(\"{}\\n\", message).into_bytes()\n}\n\nfn read_mcp_message<R: Read>(reader: &mut R) -> Result<String, String> {\n    let mut reader = BufReader::new(reader);\n    let mut first_line = String::new();\n    let bytes_read = reader\n        .read_line(&mut first_line)\n        .map_err(|e| format!(\"Failed to read MCP response: {}\", e))?;\n\n    if bytes_read == 0 {\n        return Err(\"Unexpected EOF while reading MCP response\".to_string());\n    }\n\n    let first_line_trimmed = first_line.trim();\n    if first_line_trimmed.starts_with('{') {\n        return Ok(first_line_trimmed.to_string());\n    }\n\n    let mut content_length: Option<usize> = None;\n    if let Some((name, value)) = first_line_trimmed.split_once(':') {\n        if name.eq_ignore_ascii_case(\"Content-Length\") {\n            content_length = Some(\n                value\n                    .trim()\n                    .parse::<usize>()\n                    .map_err(|e| format!(\"Invalid Content-Length header: {}\", e))?,\n            );\n        }\n    }\n\n    loop {\n        let mut line = String::new();\n        let bytes_read = reader\n            .read_line(&mut line)\n            .map_err(|e| format!(\"Failed to read MCP header: {}\", e))?;\n\n        if bytes_read == 0 {\n            return Err(\"Unexpected EOF while reading MCP headers\".to_string());\n        }\n\n        let trimmed = line.trim();\n        if trimmed.is_empty() {\n            break;\n        }\n\n        if let Some((name, value)) = trimmed.split_once(':') {\n            if name.eq_ignore_ascii_case(\"Content-Length\") {\n                content_length = Some(\n                    value\n                        .trim()\n                        .parse::<usize>()\n                        .map_err(|e| format!(\"Invalid Content-Length header: {}\", e))?,\n                );\n            }\n        }\n    }\n\n    let content_length =\n        content_length.ok_or_else(|| format!(\"Unsupported MCP response prelude: {}\", first_line_trimmed))?;\n    let mut body = vec![0; content_length];\n    reader\n        .read_exact(&mut body)\n        .map_err(|e| format!(\"Unexpected EOF while reading MCP body: {}\", e))?;\n\n    String::from_utf8(body).map_err(|e| format!(\"MCP body is not valid UTF-8: {}\", e))\n}\n\n/// 查找 npx 的完整路径\nfn find_npx_path() -> Option<String> {\n    // 常见的 npx 安装路径\n    let common_paths = vec![\n        // macOS/Linux - Volta\n        format!(\"{}/.volta/bin/npx\", std::env::var(\"HOME\").unwrap_or_default()),\n        // macOS/Linux - Homebrew\n        \"/usr/local/bin/npx\".to_string(),\n        \"/opt/homebrew/bin/npx\".to_string(),\n        // macOS/Linux - nvm\n        format!(\"{}/.nvm/versions/node/*/bin/npx\", std::env::var(\"HOME\").unwrap_or_default()),\n        // macOS/Linux - 用户本地\n        format!(\"{}/.local/bin/npx\", std::env::var(\"HOME\").unwrap_or_default()),\n        format!(\"{}/bin/npx\", std::env::var(\"HOME\").unwrap_or_default()),\n        // Windows - Volta\n        format!(\"{}\\\\AppData\\\\Local\\\\Volta\\\\bin\\\\npx.cmd\", std::env::var(\"USERPROFILE\").unwrap_or_default()),\n        // Windows - Node.js\n        \"C:\\\\Program Files\\\\nodejs\\\\npx.cmd\".to_string(),\n        format!(\"{}\\\\AppData\\\\Roaming\\\\npm\\\\npx.cmd\", std::env::var(\"USERPROFILE\").unwrap_or_default()),\n    ];\n    \n    // 首先尝试从 PATH 环境变量中查找\n    if let Ok(path_var) = std::env::var(\"PATH\") {\n        // Windows 使用分号，Unix 使用冒号\n        let separator = if cfg!(target_os = \"windows\") { ';' } else { ':' };\n\n        // 在 Windows 上优先查找 npx.cmd\n        if cfg!(target_os = \"windows\") {\n            for path in path_var.split(separator) {\n                let npx_cmd = PathBuf::from(path).join(\"npx.cmd\");\n                if npx_cmd.exists() {\n                    return Some(npx_cmd.to_string_lossy().to_string());\n                }\n            }\n            // 如果没找到 .cmd，再查找无扩展名的\n            for path in path_var.split(separator) {\n                let npx_path = PathBuf::from(path).join(\"npx\");\n                if npx_path.exists() {\n                    return Some(npx_path.to_string_lossy().to_string());\n                }\n            }\n        } else {\n            // Unix 系统：先查找 npx，再查找 npx.cmd（如果存在）\n            for path in path_var.split(separator) {\n                let npx_path = PathBuf::from(path).join(\"npx\");\n                if npx_path.exists() {\n                    return Some(npx_path.to_string_lossy().to_string());\n                }\n            }\n        }\n    }\n    \n    // 检查常见路径\n    for path in &common_paths {\n        // 处理通配符路径（nvm）\n        if path.contains('*') {\n            if let Some(parent) = path.rsplit_once('/').map(|(p, _)| p) {\n                if let Ok(entries) = std::fs::read_dir(parent.replace(\"/*\", \"\")) {\n                    for entry in entries.flatten() {\n                        let npx_path = entry.path().join(\"bin/npx\");\n                        if npx_path.exists() {\n                            let found = npx_path.to_string_lossy().to_string();\n                            return Some(found);\n                        }\n                    }\n                }\n            }\n        } else {\n            let npx_path = PathBuf::from(&path);\n            if npx_path.exists() {\n                return Some(path.clone());\n            }\n        }\n    }\n    None\n}\n\n/// 启动 stdio 类型的 MCP 服务器\n#[tauri::command]\npub async fn start_mcp_stdio_server(\n    server_id: String,\n    command: String,\n    args: Vec<String>,\n    env: HashMap<String, String>,\n    manager: State<'_, McpServerManager>,\n) -> Result<String, String> {\n    // 检查是否已经启动，如果已启动则先停止\n    {\n        let mut processes = manager.processes.lock().unwrap();\n        if let Some(old_child) = processes.remove(&server_id) {\n            if let Ok(mut old_child) = old_child.lock() {\n                let _ = old_child.kill();\n            }\n        }\n    }\n    \n    // 处理 npx 命令 - 需要找到正确的 npx 路径\n    let mut cmd = if command == \"npx\" || command.ends_with(\"/npx\") || command.ends_with(\"\\\\npx\") {\n        // 尝试找到 npx 的完整路径\n        let npx_path = find_npx_path();\n\n        if let Some(npx) = npx_path {\n            // 在 Windows 上，.cmd 和 .bat 文件需要通过 cmd.exe 执行\n            #[cfg(target_os = \"windows\")]\n            {\n                if npx.ends_with(\".cmd\") || npx.ends_with(\".bat\") {\n                    let mut cmd = Command::new(\"cmd\");\n                    cmd.args(&[\"/C\", &npx]);\n                    cmd.args(&args);\n                    cmd\n                } else {\n                    let mut cmd = Command::new(&npx);\n                    cmd.args(&args);\n                    cmd\n                }\n            }\n\n            #[cfg(not(target_os = \"windows\"))]\n            {\n                let mut cmd = Command::new(&npx);\n                cmd.args(&args);\n                cmd\n            }\n        } else {\n            // 如果找不到 npx，尝试通过 shell 执行\n            let full_command = if args.is_empty() {\n                command.clone()\n            } else {\n                format!(\"{} {}\", command, args.join(\" \"))\n            };\n\n            #[cfg(target_os = \"windows\")]\n            {\n                let mut cmd = Command::new(\"cmd\");\n                cmd.args(&[\"/C\", &full_command]);\n                cmd\n            }\n\n            #[cfg(not(target_os = \"windows\"))]\n            {\n                let mut cmd = Command::new(\"sh\");\n                cmd.args(&[\"-c\", &full_command]);\n                cmd\n            }\n        }\n    } else {\n        // 普通命令直接执行\n        let mut cmd = Command::new(&command);\n        cmd.args(&args);\n        cmd\n    };\n    \n    // 设置标准输入输出\n    cmd.stdin(Stdio::piped())\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped());\n    \n    // 设置环境变量\n    for (key, value) in env {\n        cmd.env(key, value);\n    }\n\n    // 在 Windows 上设置 CREATE_NO_WINDOW 标志，防止弹出控制台窗口\n    #[cfg(target_os = \"windows\")]\n    {\n        use windows::Win32::System::Threading::CREATE_NO_WINDOW;\n        cmd.creation_flags(CREATE_NO_WINDOW.0);\n    }\n\n    let child = cmd.spawn()\n        .map_err(|e| format!(\"Failed to spawn process: {}\", e))?;\n    let child = child;\n    \n    // 存储进程\n    {\n        let mut processes = manager.processes.lock().unwrap();\n        processes.insert(server_id.clone(), Arc::new(Mutex::new(child)));\n    }\n    \n    Ok(format!(\"Server {} started\", server_id))\n}\n\n/// 停止 MCP 服务器\n#[tauri::command]\npub async fn stop_mcp_server(\n    server_id: String,\n    manager: State<'_, McpServerManager>,\n) -> Result<(), String> {\n    let child = {\n        let mut processes = manager.processes.lock().unwrap();\n        processes.remove(&server_id)\n    };\n\n    if let Some(child) = child {\n        let mut child = child.lock().unwrap();\n        child.kill()\n            .map_err(|e| format!(\"Failed to kill process: {}\", e))?;\n\n        Ok(())\n    } else {\n        Err(format!(\"Server {} not found\", server_id))\n    }\n}\n\n/// 发送 JSON-RPC 消息到 MCP 服务器\n#[tauri::command]\npub async fn send_mcp_message(\n    server_id: String,\n    message: String,\n    manager: State<'_, McpServerManager>,\n) -> Result<String, String> {\n    let child = {\n        let processes = manager.processes.lock().unwrap();\n        processes.get(&server_id).cloned()\n    };\n\n    if let Some(child) = child {\n        let mut child = child.lock().unwrap();\n        // 获取 stdin 和 stdout\n        let payload = encode_mcp_message(&message);\n        {\n            let stdin = child.stdin.as_mut()\n                .ok_or(\"Failed to get stdin\")?;\n            stdin.write_all(&payload)\n                .map_err(|e| format!(\"Failed to write framed MCP message: {}\", e))?;\n            \n            stdin.flush()\n                .map_err(|e| format!(\"Failed to flush stdin: {}\", e))?;\n        }\n\n        let stdout = child.stdout.as_mut()\n            .ok_or(\"Failed to get stdout\")?;\n        read_mcp_message(stdout)\n    } else {\n        Err(format!(\"Server {} not found\", server_id))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{encode_mcp_message, read_mcp_message};\n    use std::io::Cursor;\n\n    #[test]\n    fn writes_newline_delimited_message() {\n        let body = r#\"{\"jsonrpc\":\"2.0\",\"id\":1}\"#;\n        let encoded = encode_mcp_message(body);\n\n        assert_eq!(\n            encoded,\n            format!(\"{}\\n\", body).into_bytes()\n        );\n    }\n\n    #[test]\n    fn reads_single_json_line_message() {\n        let body = r#\"{\"jsonrpc\":\"2.0\",\"result\":{\"ok\":true}}\"#;\n        let payload = format!(\"{}\\n\", body);\n        let mut cursor = Cursor::new(payload.into_bytes());\n\n        let read = read_mcp_message(&mut cursor).expect(\"should parse json line body\");\n\n        assert_eq!(read, body);\n    }\n\n    #[test]\n    fn reads_single_framed_message() {\n        let body = r#\"{\"jsonrpc\":\"2.0\",\"result\":{\"ok\":true}}\"#;\n        let payload = format!(\"Content-Length: {}\\r\\n\\r\\n{}\", body.len(), body);\n        let mut cursor = Cursor::new(payload.into_bytes());\n\n        let read = read_mcp_message(&mut cursor).expect(\"should parse framed body\");\n\n        assert_eq!(read, body);\n    }\n\n    #[test]\n    fn rejects_missing_content_length_header() {\n        let payload = b\"X-Test: 1\\r\\n\\r\\n{}\".to_vec();\n        let mut cursor = Cursor::new(payload);\n\n        let error = read_mcp_message(&mut cursor).expect_err(\"should reject invalid frame\");\n\n        assert!(error.contains(\"Unsupported MCP response prelude\"));\n    }\n\n    #[test]\n    fn rejects_truncated_framed_message() {\n        let payload = b\"Content-Length: 10\\r\\n\\r\\n{}\".to_vec();\n        let mut cursor = Cursor::new(payload);\n\n        let error = read_mcp_message(&mut cursor).expect_err(\"should reject short body\");\n\n        assert!(error.contains(\"Unexpected EOF\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/mcp_runtime.rs",
    "content": "use serde::Serialize;\nuse std::collections::{HashMap, HashSet};\nuse std::env;\nuse std::path::{Path, PathBuf};\nuse std::process::{Command, Stdio};\nuse std::sync::Arc;\nuse tauri::{AppHandle, Emitter, Runtime};\nuse tauri::State;\nuse tokio::io::{AsyncBufReadExt, BufReader};\nuse tokio::process::Command as TokioCommand;\nuse tokio::sync::Mutex;\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum RuntimeKind {\n    Npx,\n    Uvx,\n    Python,\n    Python3,\n    Bunx,\n    Unknown,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct RuntimeRequirement {\n    pub launcher: String,\n    pub kind: RuntimeKind,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct InstallRecipe {\n    pub id: &'static str,\n    pub title: &'static str,\n    pub command_preview: &'static str,\n    pub post_install_hint: Option<&'static str>,\n    pub scope: &'static str,\n    pub manual_only: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct RuntimeCheckResult {\n    pub command: String,\n    pub installed: bool,\n    pub resolved_path: Option<String>,\n    pub version: Option<String>,\n    pub error: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct RuntimeInspection {\n    pub launcher: String,\n    pub kind: RuntimeKind,\n    pub checks: Vec<RuntimeCheckResult>,\n    pub install_recipe: Option<InstallRecipe>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct InstallExecutionResult {\n    pub recipe_id: String,\n    pub success: bool,\n    pub stdout: String,\n    pub stderr: String,\n    pub exit_code: Option<i32>,\n}\n\n#[derive(Default)]\npub struct RuntimeInstallManager {\n    active_installs: Arc<Mutex<HashMap<String, u32>>>,\n    cancelled_installs: Arc<Mutex<HashSet<String>>>,\n}\n\nimpl RuntimeInstallManager {\n    pub fn new() -> Self {\n        Self::default()\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum InstallProgressStage {\n    Preparing,\n    Running,\n    Cancelled,\n    Completed,\n    Failed,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct InstallProgressEvent {\n    pub recipe_id: String,\n    pub stage: InstallProgressStage,\n    pub stream: Option<String>,\n    pub line: Option<String>,\n    pub exit_code: Option<i32>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct CancelInstallResult {\n    pub recipe_id: String,\n    pub cancelled: bool,\n}\n\nfn normalize_launcher(command: &str, args: &[String]) -> String {\n    let trimmed = command.trim();\n    let candidate = if trimmed.is_empty() {\n        args.first().map(String::as_str).unwrap_or(\"\")\n    } else {\n        trimmed.split_whitespace().next().unwrap_or(\"\")\n    };\n\n    let basename = Path::new(candidate)\n        .file_name()\n        .and_then(|name| name.to_str())\n        .unwrap_or(candidate)\n        .to_lowercase();\n\n    basename\n        .trim_end_matches(\".cmd\")\n        .trim_end_matches(\".exe\")\n        .trim_end_matches(\".bat\")\n        .to_string()\n}\n\npub fn classify_runtime_requirement(command: &str, args: &[String]) -> RuntimeRequirement {\n    let launcher = normalize_launcher(command, args);\n    let kind = match launcher.as_str() {\n        \"npx\" => RuntimeKind::Npx,\n        \"uvx\" => RuntimeKind::Uvx,\n        \"python\" => RuntimeKind::Python,\n        \"python3\" => RuntimeKind::Python3,\n        \"bunx\" => RuntimeKind::Bunx,\n        _ => RuntimeKind::Unknown,\n    };\n\n    RuntimeRequirement { launcher, kind }\n}\n\npub fn install_recipe_for(kind: &RuntimeKind, platform: &str) -> Option<InstallRecipe> {\n    match (kind, platform) {\n        (RuntimeKind::Uvx, \"macos\") => Some(InstallRecipe {\n            id: \"install-uv-macos\",\n            title: \"Install uv\",\n            command_preview: \"curl -LsSf https://astral.sh/uv/install.sh | sh\",\n            post_install_hint: Some(\"If uv is still unavailable after installation, restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Uvx, \"linux\") => Some(InstallRecipe {\n            id: \"install-uv-linux\",\n            title: \"Install uv\",\n            command_preview: \"curl -LsSf https://astral.sh/uv/install.sh | sh\",\n            post_install_hint: Some(\"If uv is still unavailable after installation, restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Uvx, \"windows\") => Some(InstallRecipe {\n            id: \"install-uv-windows\",\n            title: \"Install uv\",\n            command_preview: \"powershell -ExecutionPolicy Bypass -c \\\"irm https://astral.sh/uv/install.ps1 | iex\\\"\",\n            post_install_hint: Some(\"If uv is still unavailable after installation, restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Bunx, \"macos\") => Some(InstallRecipe {\n            id: \"install-bun-macos\",\n            title: \"Install Bun\",\n            command_preview: \"curl -fsSL https://bun.com/install | bash\",\n            post_install_hint: Some(\"Bun installs into ~/.bun/bin. If bun is still unavailable after installation, add that directory to PATH, then restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Bunx, \"linux\") => Some(InstallRecipe {\n            id: \"install-bun-linux\",\n            title: \"Install Bun\",\n            command_preview: \"curl -fsSL https://bun.com/install | bash\",\n            post_install_hint: Some(\"Bun installs into ~/.bun/bin and requires unzip on Linux. If bun is still unavailable after installation, add that directory to PATH, then restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Bunx, \"windows\") => Some(InstallRecipe {\n            id: \"install-bun-windows\",\n            title: \"Install Bun\",\n            command_preview: \"powershell -c \\\"irm bun.com/install.ps1 | iex\\\"\",\n            post_install_hint: Some(\"If bun is still unavailable after installation, restart NoteGen or open a new terminal session and re-check your PATH.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Npx, \"macos\") | (RuntimeKind::Npx, \"linux\") => Some(InstallRecipe {\n            id: \"install-node-volta-unix\",\n            title: \"Install Node.js via Volta\",\n            command_preview: \"curl https://get.volta.sh | bash && export VOLTA_HOME=\\\"$HOME/.volta\\\" && export PATH=\\\"$VOLTA_HOME/bin:$PATH\\\" && volta install node\",\n            post_install_hint: Some(\"Volta updates shell configuration for future sessions. If npx is still unavailable after installation, restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Npx, \"windows\") => Some(InstallRecipe {\n            id: \"install-node-volta-windows\",\n            title: \"Install Node.js via Volta\",\n            command_preview: \"winget install Volta.Volta && volta install node\",\n            post_install_hint: Some(\"Windows may not expose Volta in the current session immediately. If npx is still unavailable after installation, restart NoteGen or open a new terminal session and re-check.\"),\n            scope: \"current_user\",\n            manual_only: false,\n        }),\n        (RuntimeKind::Python, \"macos\")\n        | (RuntimeKind::Python3, \"macos\")\n        | (RuntimeKind::Python, \"windows\")\n        | (RuntimeKind::Python3, \"windows\")\n        | (RuntimeKind::Python, \"linux\")\n        | (RuntimeKind::Python3, \"linux\") => Some(InstallRecipe {\n            id: \"install-python-manual\",\n            title: \"Install Python\",\n            command_preview: \"Install Python 3 in your user environment, then re-check in NoteGen.\",\n            post_install_hint: Some(\"Use the official Python installer for your platform, then restart NoteGen or open a new terminal session before re-checking.\"),\n            scope: \"current_user\",\n            manual_only: true,\n        }),\n        _ => None,\n    }\n}\n\nfn current_platform() -> &'static str {\n    if cfg!(target_os = \"macos\") {\n        \"macos\"\n    } else if cfg!(target_os = \"windows\") {\n        \"windows\"\n    } else {\n        \"linux\"\n    }\n}\n\nfn find_command_path(command: &str) -> Option<PathBuf> {\n    let path_var = env::var(\"PATH\").ok()?;\n    let separator = if cfg!(target_os = \"windows\") { ';' } else { ':' };\n    let candidates: &[&str] = if cfg!(target_os = \"windows\") {\n        &[command, &format!(\"{command}.cmd\"), &format!(\"{command}.exe\"), &format!(\"{command}.bat\")]\n    } else {\n        &[command]\n    };\n\n    for dir in path_var.split(separator) {\n        for candidate in candidates {\n            let path = PathBuf::from(dir).join(candidate);\n            if path.exists() {\n                return Some(path);\n            }\n        }\n    }\n\n    None\n}\n\nfn version_args_for(command: &str) -> &'static [&'static str] {\n    match command {\n        \"python\" | \"python3\" => &[\"--version\"],\n        _ => &[\"--version\"],\n    }\n}\n\nfn read_command_version(command: &str, path: &Path) -> Result<Option<String>, String> {\n    let output = Command::new(path)\n        .args(version_args_for(command))\n        .output()\n        .map_err(|error| format!(\"Failed to read version: {error}\"))?;\n\n    if !output.status.success() {\n        return Ok(None);\n    }\n\n    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();\n    if !stdout.is_empty() {\n        return Ok(Some(stdout));\n    }\n\n    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();\n    if !stderr.is_empty() {\n        return Ok(Some(stderr));\n    }\n\n    Ok(None)\n}\n\nfn inspect_requirement(requirement: &RuntimeRequirement) -> RuntimeInspection {\n    let resolved_path = find_command_path(&requirement.launcher);\n    let version = resolved_path\n        .as_ref()\n        .and_then(|path| read_command_version(&requirement.launcher, path).ok().flatten());\n\n    let check = RuntimeCheckResult {\n        command: requirement.launcher.clone(),\n        installed: resolved_path.is_some(),\n        resolved_path: resolved_path.map(|path| path.to_string_lossy().to_string()),\n        version,\n        error: None,\n    };\n\n    RuntimeInspection {\n        launcher: requirement.launcher.clone(),\n        kind: requirement.kind.clone(),\n        checks: vec![check],\n        install_recipe: install_recipe_for(&requirement.kind, current_platform()),\n    }\n}\n\nfn install_recipe_command(recipe_id: &str) -> Option<(&'static str, Vec<&'static str>)> {\n    match recipe_id {\n        \"install-uv-macos\" | \"install-uv-linux\" => Some((\n            \"sh\",\n            vec![\"-lc\", \"curl -LsSf https://astral.sh/uv/install.sh | sh\"],\n        )),\n        \"install-uv-windows\" => Some((\n            \"powershell\",\n            vec![\"-ExecutionPolicy\", \"Bypass\", \"-c\", \"irm https://astral.sh/uv/install.ps1 | iex\"],\n        )),\n        \"install-bun-macos\" | \"install-bun-linux\" => Some((\n            \"sh\",\n            vec![\"-lc\", \"curl -fsSL https://bun.com/install | bash\"],\n        )),\n        \"install-bun-windows\" => Some((\n            \"powershell\",\n            vec![\"-c\", \"irm bun.com/install.ps1 | iex\"],\n        )),\n        \"install-node-volta-unix\" => Some((\n            \"sh\",\n            vec![\n                \"-lc\",\n                \"curl https://get.volta.sh | bash && export VOLTA_HOME=\\\"$HOME/.volta\\\" && export PATH=\\\"$VOLTA_HOME/bin:$PATH\\\" && volta install node\",\n            ],\n        )),\n        \"install-node-volta-windows\" => Some((\n            \"cmd\",\n            vec![\"/C\", \"winget install Volta.Volta && volta install node\"],\n        )),\n        _ => None,\n    }\n}\n\n#[cfg(unix)]\nfn configure_install_command(command: &mut TokioCommand) {\n    command.process_group(0);\n}\n\n#[cfg(windows)]\nfn configure_install_command(_command: &mut TokioCommand) {}\n\n#[cfg(not(any(unix, windows)))]\nfn configure_install_command(_command: &mut TokioCommand) {}\n\n#[cfg(unix)]\nasync fn kill_install_process(pid: u32) -> Result<(), String> {\n    let target = format!(\"-{pid}\");\n    TokioCommand::new(\"kill\")\n        .args([\"-TERM\", target.as_str()])\n        .status()\n        .await\n        .map_err(|error| format!(\"Failed to send TERM to install process group: {error}\"))?;\n    Ok(())\n}\n\n#[cfg(windows)]\nasync fn kill_install_process(pid: u32) -> Result<(), String> {\n    TokioCommand::new(\"taskkill\")\n        .args([\"/PID\", &pid.to_string(), \"/T\", \"/F\"])\n        .status()\n        .await\n        .map_err(|error| format!(\"Failed to stop install process tree: {error}\"))?;\n    Ok(())\n}\n\n#[cfg(not(any(unix, windows)))]\nasync fn kill_install_process(_pid: u32) -> Result<(), String> {\n    Err(\"Install cancellation is not supported on this platform\".to_string())\n}\n\nfn final_install_stage(success: bool, cancelled: bool) -> InstallProgressStage {\n    if cancelled {\n        InstallProgressStage::Cancelled\n    } else if success {\n        InstallProgressStage::Completed\n    } else {\n        InstallProgressStage::Failed\n    }\n}\n\nfn emit_install_event<R: Runtime>(\n    app: &AppHandle<R>,\n    recipe_id: &str,\n    stage: InstallProgressStage,\n    stream: Option<&str>,\n    line: Option<String>,\n    exit_code: Option<i32>,\n) -> Result<(), String> {\n    app.emit(\n        \"mcp-runtime-install\",\n        InstallProgressEvent {\n            recipe_id: recipe_id.to_string(),\n            stage,\n            stream: stream.map(str::to_string),\n            line,\n            exit_code,\n        },\n    )\n    .map_err(|error| format!(\"Failed to emit install progress: {error}\"))\n}\n\nasync fn collect_process_output<R: Runtime>(\n    app: AppHandle<R>,\n    recipe_id: String,\n    stream_name: &'static str,\n    reader: impl tokio::io::AsyncRead + Unpin,\n    buffer: Arc<Mutex<String>>,\n) -> Result<(), String> {\n    let mut lines = BufReader::new(reader).lines();\n    while let Some(line) = lines\n        .next_line()\n        .await\n        .map_err(|error| format!(\"Failed to read install output: {error}\"))?\n    {\n        {\n            let mut locked = buffer.lock().await;\n            locked.push_str(&line);\n            locked.push('\\n');\n        }\n\n        emit_install_event(\n            &app,\n            &recipe_id,\n            InstallProgressStage::Running,\n            Some(stream_name),\n            Some(line),\n            None,\n        )?;\n    }\n\n    Ok(())\n}\n\n#[tauri::command]\npub async fn inspect_mcp_runtime(command: String, args: Vec<String>) -> Result<RuntimeInspection, String> {\n    let requirement = classify_runtime_requirement(&command, &args);\n    Ok(inspect_requirement(&requirement))\n}\n\n#[tauri::command]\npub async fn install_mcp_runtime(\n    app: AppHandle,\n    manager: State<'_, RuntimeInstallManager>,\n    recipe_id: String,\n) -> Result<InstallExecutionResult, String> {\n    let (shell, args) = install_recipe_command(&recipe_id)\n        .ok_or_else(|| format!(\"Unsupported install recipe: {recipe_id}\"))?;\n\n    emit_install_event(\n        &app,\n        &recipe_id,\n        InstallProgressStage::Preparing,\n        None,\n        Some(format!(\"Starting install command: {shell} {}\", args.join(\" \"))),\n        None,\n    )?;\n\n    let mut command = TokioCommand::new(shell);\n    command\n        .args(&args)\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped());\n    configure_install_command(&mut command);\n\n    let mut child = command\n        .spawn()\n        .map_err(|error| format!(\"Failed to execute install recipe: {error}\"))?;\n    let pid = child\n        .id()\n        .ok_or_else(|| \"Failed to determine install process id\".to_string())?;\n    {\n        let mut active_installs = manager.active_installs.lock().await;\n        active_installs.insert(recipe_id.clone(), pid);\n    }\n\n    emit_install_event(\n        &app,\n        &recipe_id,\n        InstallProgressStage::Running,\n        None,\n        Some(\"Install command is running...\".to_string()),\n        None,\n    )?;\n\n    let stdout = child\n        .stdout\n        .take()\n        .ok_or_else(|| \"Failed to capture install stdout\".to_string())?;\n    let stderr = child\n        .stderr\n        .take()\n        .ok_or_else(|| \"Failed to capture install stderr\".to_string())?;\n\n    let stdout_buffer = Arc::new(Mutex::new(String::new()));\n    let stderr_buffer = Arc::new(Mutex::new(String::new()));\n\n    let stdout_task = tokio::spawn(collect_process_output(\n        app.clone(),\n        recipe_id.clone(),\n        \"stdout\",\n        stdout,\n        stdout_buffer.clone(),\n    ));\n    let stderr_task = tokio::spawn(collect_process_output(\n        app.clone(),\n        recipe_id.clone(),\n        \"stderr\",\n        stderr,\n        stderr_buffer.clone(),\n    ));\n\n    let status = child\n        .wait()\n        .await\n        .map_err(|error| format!(\"Failed to wait for install recipe: {error}\"))?;\n\n    stdout_task\n        .await\n        .map_err(|error| format!(\"Failed to collect stdout task: {error}\"))??;\n    stderr_task\n        .await\n        .map_err(|error| format!(\"Failed to collect stderr task: {error}\"))??;\n\n    {\n        let mut active_installs = manager.active_installs.lock().await;\n        active_installs.remove(&recipe_id);\n    }\n\n    let cancelled = {\n        let mut cancelled_installs = manager.cancelled_installs.lock().await;\n        cancelled_installs.remove(&recipe_id)\n    };\n\n    let success = status.success() && !cancelled;\n    let exit_code = status.code();\n    emit_install_event(\n        &app,\n        &recipe_id,\n        final_install_stage(success, cancelled),\n        None,\n        Some(if cancelled {\n            \"Install command was cancelled.\".to_string()\n        } else if success {\n            \"Install command completed successfully.\".to_string()\n        } else {\n            \"Install command failed.\".to_string()\n        }),\n        exit_code,\n    )?;\n\n    let stdout = stdout_buffer.lock().await.clone();\n    let stderr = stderr_buffer.lock().await.clone();\n\n    Ok(InstallExecutionResult {\n        recipe_id,\n        success,\n        stdout,\n        stderr,\n        exit_code,\n    })\n}\n\n#[tauri::command]\npub async fn cancel_mcp_runtime_install(\n    app: AppHandle,\n    manager: State<'_, RuntimeInstallManager>,\n    recipe_id: String,\n) -> Result<CancelInstallResult, String> {\n    let pid = {\n        let active_installs = manager.active_installs.lock().await;\n        active_installs.get(&recipe_id).copied()\n    };\n\n    let Some(pid) = pid else {\n        return Ok(CancelInstallResult {\n            recipe_id,\n            cancelled: false,\n        });\n    };\n\n    {\n        let mut cancelled_installs = manager.cancelled_installs.lock().await;\n        cancelled_installs.insert(recipe_id.clone());\n    }\n\n    kill_install_process(pid).await?;\n\n    emit_install_event(\n        &app,\n        &recipe_id,\n        InstallProgressStage::Cancelled,\n        None,\n        Some(\"Cancellation requested by user.\".to_string()),\n        None,\n    )?;\n\n    Ok(CancelInstallResult {\n        recipe_id,\n        cancelled: true,\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        classify_runtime_requirement, final_install_stage, install_recipe_for,\n        InstallProgressStage, RuntimeInstallManager, RuntimeKind,\n    };\n\n    #[test]\n    fn classifies_combined_npx_command() {\n        let requirement = classify_runtime_requirement(\n            \"npx @modelcontextprotocol/server-filesystem\",\n            &[],\n        );\n\n        assert_eq!(requirement.launcher, \"npx\");\n        assert_eq!(requirement.kind, RuntimeKind::Npx);\n    }\n\n    #[test]\n    fn classifies_python_command_with_args() {\n        let requirement = classify_runtime_requirement(\n            \"python3\",\n            &[\"-m\".into(), \"mcp_server\".into()],\n        );\n\n        assert_eq!(requirement.launcher, \"python3\");\n        assert_eq!(requirement.kind, RuntimeKind::Python3);\n    }\n\n    #[test]\n    fn returns_macos_recipe_for_uvx() {\n        let recipe = install_recipe_for(&RuntimeKind::Uvx, \"macos\")\n            .expect(\"uvx should have a macOS install recipe\");\n\n        assert_eq!(recipe.id, \"install-uv-macos\");\n    }\n\n    #[test]\n    fn uses_bun_dot_com_installer_for_bun() {\n        let recipe = install_recipe_for(&RuntimeKind::Bunx, \"macos\")\n            .expect(\"bunx should have a macOS install recipe\");\n\n        assert!(recipe.command_preview.contains(\"https://bun.com/install\"));\n    }\n\n    #[test]\n    fn returns_none_for_unknown_runtime() {\n        let recipe = install_recipe_for(&RuntimeKind::Unknown, \"macos\");\n\n        assert!(recipe.is_none());\n    }\n\n    #[test]\n    fn final_install_stage_returns_completed_on_success() {\n        assert_eq!(\n            final_install_stage(true, false),\n            InstallProgressStage::Completed\n        );\n    }\n\n    #[test]\n    fn final_install_stage_returns_failed_on_error() {\n        assert_eq!(final_install_stage(false, false), InstallProgressStage::Failed);\n    }\n\n    #[test]\n    fn final_install_stage_returns_cancelled_when_requested() {\n        assert_eq!(final_install_stage(false, true), InstallProgressStage::Cancelled);\n    }\n\n    #[test]\n    fn runtime_install_manager_starts_empty() {\n        let manager = RuntimeInstallManager::new();\n\n        assert!(manager.active_installs.try_lock().unwrap().is_empty());\n        assert!(manager.cancelled_installs.try_lock().unwrap().is_empty());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/screenshot.rs",
    "content": "use tauri::{path::BaseDirectory, AppHandle, Manager};\nuse xcap::{Window};\n\n#[cfg(target_os = \"macos\")]\nuse core_graphics::display::CGDisplay;\n\n#[derive(serde::Serialize, serde::Deserialize)]\n#[derive(Clone)]\npub struct ScreenshotImage {\n    name: String,\n    path: String,\n    width: u32,\n    height: u32,\n    x: i32,\n    y: i32,\n    z: i32,\n}\n\nfn normalized(s: &str) -> String {\n    s.replace(\" \", \"-\")\n    .replace(\"/\", \"-\")\n    .replace(\"\\\\\", \"-\")\n    .replace(\"*\", \"-\")\n    .replace(\"?\", \"-\")\n    .replace(\":\", \"-\")\n    .replace(\"<\", \"-\")\n    .replace(\">\", \"-\")\n    .replace(\"|\", \"-\")\n}\n\n#[allow(dead_code)]\n#[tauri::command]\npub fn screenshot(app: AppHandle) -> Vec<ScreenshotImage> {\n    #[cfg(target_os = \"macos\")]\n    {\n        let display = CGDisplay::main();\n        let _ = display.image();\n    }\n    \n    let windows = Window::all().unwrap();\n\n    let temp_screenshot_folder = app\n        .path()\n        .resolve(\"temp_screenshot\", BaseDirectory::AppData)\n        .unwrap();\n    if std::fs::metadata(&temp_screenshot_folder).is_ok() {\n        std::fs::remove_dir_all(&temp_screenshot_folder).unwrap();\n    }\n    std::fs::create_dir(&temp_screenshot_folder).unwrap();\n\n    let mut files: Vec<ScreenshotImage> = Vec::new();\n\n    let mut i = 0;\n    for window in windows {\n        // 已最小化的窗口跳过\n        if window.is_minimized().unwrap() {\n            continue;\n        }\n        \n        // 获取窗口属性\n        let title = window.title().unwrap_or_default();\n        let width = window.width().unwrap_or(0);\n        let height = window.height().unwrap_or(0);\n        let x = window.x().unwrap_or(0);\n        let y = window.y().unwrap_or(0);\n        let z = window.z().unwrap_or(0);\n        let system_titles = vec![\"Dock\", \"Menu Bar\", \"MenuBar\", \"Status\", \"Notification Center\", \"\", \"Desktop\", \"NoteGen\"];\n        \n        if system_titles.contains(&title.as_str()) || \n           title.len() < 2 ||\n           width < 150 || \n           height < 150 {\n            continue;\n        }\n        \n        let image = window.capture_image().unwrap();\n        let path = format!(\n            \"{}/window-{}-{}.png\",\n            temp_screenshot_folder.display(),\n            i,\n            normalized(&window.title().unwrap())\n        );\n        match image.save(&path) {\n            Ok(_) => println!(\"保存成功: {:?}\", path),\n            Err(e) => println!(\"保存失败: {:?}\", e),\n        };\n        files.push(ScreenshotImage {\n            name: title,\n            path,\n            width,\n            height,\n            x,\n            y,\n            z,\n        });\n\n        i += 1;\n    }\n    files\n}\n"
  },
  {
    "path": "src-tauri/src/skills.rs",
    "content": "use std::path::Path;\nuse std::fs;\nuse tauri::{command, AppHandle, Manager};\nuse zip::ZipArchive;\n\n#[command]\npub async fn import_skill_zip(app_handle: AppHandle, zip_path: String) -> Result<String, String> {\n    let app_data_dir = app_handle\n        .path()\n        .app_data_dir()\n        .map_err(|e| format!(\"Failed to get app data directory: {}\", e))?;\n\n    // 确保 skills 目录存在\n    let skills_dir = app_data_dir.join(\"skills\");\n    if !skills_dir.exists() {\n        fs::create_dir_all(&skills_dir)\n            .map_err(|e| format!(\"Failed to create skills directory: {}\", e))?;\n    }\n\n    // 创建临时目录用于解压\n    let temp_dir = app_data_dir.join(\"temp_skill_import\");\n    if temp_dir.exists() {\n        fs::remove_dir_all(&temp_dir)\n            .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n    }\n    fs::create_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to create temp directory: {}\", e))?;\n\n    // 使用 zip crate 解压到临时目录\n    let file = fs::File::open(&zip_path)\n        .map_err(|e| format!(\"Failed to open zip file: {}\", e))?;\n    let mut archive = ZipArchive::new(file)\n        .map_err(|e| format!(\"Failed to read zip archive: {}\", e))?;\n\n    for i in 0..archive.len() {\n        let mut file = archive.by_index(i)\n            .map_err(|e| format!(\"Failed to read zip entry: {}\", e))?;\n        let outpath = temp_dir.join(file.mangled_name());\n\n        if file.name().ends_with('/') {\n            fs::create_dir_all(&outpath)\n                .map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n        } else {\n            if let Some(p) = outpath.parent() {\n                if !p.exists() {\n                    fs::create_dir_all(p)\n                        .map_err(|e| format!(\"Failed to create parent directory: {}\", e))?;\n                }\n            }\n            let mut outfile = fs::File::create(&outpath)\n                .map_err(|e| format!(\"Failed to create file: {}\", e))?;\n            std::io::copy(&mut file, &mut outfile)\n                .map_err(|e| format!(\"Failed to extract file: {}\", e))?;\n        }\n    }\n\n    // 查找解压后的目录\n    let entries = fs::read_dir(&temp_dir)\n        .map_err(|e| format!(\"Failed to read temp directory: {}\", e))?;\n\n    let mut skill_name = String::new();\n\n    // 查找包含 SKILL.md 的目录\n    for entry in entries {\n        let entry = entry.map_err(|e| format!(\"Failed to read directory entry: {}\", e))?;\n        let path = entry.path();\n\n        if path.is_dir() {\n            // 检查是否包含 SKILL.md\n            let skill_md = path.join(\"SKILL.md\");\n            if skill_md.exists() {\n                skill_name = path.file_name()\n                    .and_then(|n| n.to_str())\n                    .ok_or(\"Failed to get skill directory name\")?\n                    .to_string();\n\n                let dest_path = skills_dir.join(&skill_name);\n\n                // 如果目标目录已存在，先删除\n                if dest_path.exists() {\n                    fs::remove_dir_all(&dest_path)\n                        .map_err(|e| format!(\"Failed to remove existing skill directory: {}\", e))?;\n                }\n\n                // 移动目录到 skills 目录\n                fs::rename(&path, &dest_path)\n                    .or_else(|_| {\n                        // 如果 rename 失败（可能跨文件系统），尝试复制\n                        copy_dir_recursive(&path, &dest_path)\n                    })\n                    .map_err(|e| format!(\"Failed to move skill directory: {}\", e))?;\n\n                break;\n            }\n        }\n    }\n\n    // 清理临时目录\n    fs::remove_dir_all(&temp_dir)\n        .map_err(|e| format!(\"Failed to remove temp directory: {}\", e))?;\n\n    if skill_name.is_empty() {\n        return Err(\"No valid skill found in zip file. A valid skill must contain a SKILL.md file.\".to_string());\n    }\n\n    Ok(skill_name)\n}\n\n// 递归复制目录的辅助函数\nfn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), String> {\n    if !dest.exists() {\n        fs::create_dir_all(dest).map_err(|e| format!(\"Failed to create directory: {}\", e))?;\n    }\n\n    for entry in fs::read_dir(src).map_err(|e| format!(\"Failed to read source directory: {}\", e))? {\n        let entry = entry.map_err(|e| format!(\"Failed to read directory entry: {}\", e))?;\n        let src_path = entry.path();\n        let dest_path = dest.join(entry.file_name());\n\n        if src_path.is_file() {\n            fs::copy(&src_path, &dest_path)\n                .map_err(|e| format!(\"Failed to copy file: {}\", e))?;\n        } else if src_path.is_dir() {\n            copy_dir_recursive(&src_path, &dest_path)?;\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/statusbar.rs",
    "content": "use tauri::command;\n\n#[command]\npub fn set_statusbar_color(color: String, is_dark: bool) -> Result<(), String> {\n    #[cfg(target_os = \"android\")]\n    {\n        use tauri::Manager;\n        // Android 状态栏颜色设置需要通过 WebView 的 evaluateJavascript 来调用 Android API\n        // 这里我们返回成功，实际的颜色设置会在前端通过 WebView 的方式处理\n        Ok(())\n    }\n    \n    #[cfg(not(target_os = \"android\"))]\n    {\n        // 非 Android 平台不需要设置\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/tray.rs",
    "content": "use tauri::{\n    image::Image,\n    menu::{Menu, MenuItem},\n    tray::TrayIconBuilder,\n    AppHandle, Manager, Runtime,\n};\nuse tauri::Emitter;\n\npub const ID_SHOW_MAIN: &str = \"show-main\";\npub const ID_SETTINGS: &str = \"settings\";\npub const ID_QUIT: &str = \"quit\";\n\npub fn create_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<tauri::tray::TrayIcon<R>> {\n    let show_main = MenuItem::with_id(app, ID_SHOW_MAIN, \"显示主窗口\", true, None::<&str>)?;\n    let settings = MenuItem::with_id(app, ID_SETTINGS, \"设置\", true, None::<&str>)?;\n    let quit = MenuItem::with_id(app, ID_QUIT, \"退出应用\", true, None::<&str>)?;\n\n    let menu = Menu::with_items(app, &[&show_main, &settings, &quit])?;\n\n    let icon = Image::from_bytes(include_bytes!(\"../icons/icon.png\"))?;\n\n    let tray = TrayIconBuilder::new()\n        .icon(icon)\n        .menu(&menu)\n        .tooltip(\"NoteGen\")\n        .on_menu_event(move |app, event| {\n            handle_menu_event(app, event.id.0.as_str());\n        })\n        .on_tray_icon_event(move |tray, event| {\n            if let tauri::tray::TrayIconEvent::Click {\n                button: tauri::tray::MouseButton::Left,\n                ..\n            } = event\n            {\n                let app_handle = tray.app_handle();\n                if let Some(webview) = app_handle.get_webview_window(\"main\") {\n                    let _ = webview.show();\n                    let _ = webview.set_focus();\n                }\n            }\n        })\n        .build(app)?;\n\n    Ok(tray)\n}\n\nfn handle_menu_event<R: Runtime>(app: &AppHandle<R>, id: &str) {\n    match id {\n        ID_SHOW_MAIN => {\n            if let Some(webview) = app.get_webview_window(\"main\") {\n                let _ = webview.show();\n                let _ = webview.set_focus();\n            }\n        }\n        ID_SETTINGS => {\n            if let Some(webview) = app.get_webview_window(\"main\") {\n                let _ = webview.show();\n                let _ = webview.set_focus();\n                let _ = webview.emit(\"open-settings\", \"\");\n            }\n        }\n        ID_QUIT => {\n            app.exit(0);\n        }\n        _ => {}\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/window.rs",
    "content": "use tauri::{Manager, WindowEvent, AppHandle};\n\npub fn setup_window_events(app: &AppHandle) -> tauri::Result<()> {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let window_clone = window.clone();\n        let app_handle = app.clone();\n        window.on_window_event(move |event| {\n            handle_window_event(event, &window_clone, &app_handle);\n        });\n    }\n    Ok(())\n}\n\n#[cfg(target_os = \"macos\")]\nfn handle_window_event(event: &WindowEvent, window: &tauri::WebviewWindow, _app_handle: &AppHandle) {\n    match event {\n        WindowEvent::CloseRequested { api, .. } => {\n            // 有托盘：隐藏到托盘\n            api.prevent_close();\n            let _ = window.hide();\n        }\n        _ => {}\n    }\n}\n\n#[cfg(not(target_os = \"macos\"))]\nfn handle_window_event(event: &WindowEvent, window: &tauri::WebviewWindow, _app_handle: &AppHandle) {\n    match event {\n        WindowEvent::CloseRequested { api, .. } => {\n            // 有托盘：隐藏到托盘\n            api.prevent_close();\n            let _ = window.hide();\n        }\n        _ => {}\n    }\n}\n\npub fn handle_single_instance(app: &AppHandle, _argv: Vec<String>, _cwd: String) {\n    if let Some(window) = app.get_webview_window(\"main\") {\n        let is_visible = window.is_visible().unwrap_or(false);\n        let is_minimized = window.is_minimized().unwrap_or(false);\n\n        if !is_visible {\n            let _ = window.show();\n            let _ = window.set_focus();\n            let _ = window.set_always_on_top(true);\n            let _ = window.set_always_on_top(false);\n        } else if is_minimized {\n            let _ = window.unminimize();\n            std::thread::sleep(std::time::Duration::from_millis(100));\n            let _ = window.show();\n            let _ = window.set_focus();\n            let _ = window.set_always_on_top(true);\n            let _ = window.set_always_on_top(false);\n        } else {\n            let _ = window.set_focus();\n            let _ = window.set_always_on_top(true);\n            let _ = window.set_always_on_top(false);\n        }\n    }\n}\n\n#[cfg(target_os = \"macos\")]\npub fn handle_macos_reopen(app_handle: &AppHandle, has_visible_windows: bool) {\n    if !has_visible_windows {\n        if let Some(window) = app_handle.get_webview_window(\"main\") {\n            let _ = window.show();\n            let _ = window.unminimize();\n            let _ = window.set_focus();\n            let _ = app_handle.show();\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"NoteGen\",\n  \"version\": \"0.27.1\",\n  \"identifier\": \"com.codexu.NoteGen\",\n  \"build\": {\n    \"beforeDevCommand\": \"pnpm dev\",\n    \"devUrl\": \"http://localhost:3456\",\n    \"beforeBuildCommand\": \"pnpm build\",\n    \"frontendDist\": \"../out\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": true,\n    \"macOSPrivateApi\": true,\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": [\"**\"]\n      }\n    },\n    \"windows\": [\n      {\n        \"title\": \"\",\n        \"label\": \"main\",\n        \"width\": 1360,\n        \"height\": 720,\n        \"dragDropEnabled\": false,\n        \"titleBarStyle\": \"Overlay\"\n      }\n    ]\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"createUpdaterArtifacts\": true,\n    \"targets\": \"all\",\n    \"resources\": [\n      \"icons\"\n    ],\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"iOS\": {\n      \"bundleVersion\": \"0.27.1\",\n      \"developmentTeam\": \"RGZC4ZTJMU\"\n    },\n    \"macOS\": {\n      \"minimumSystemVersion\": \"10.13\",\n      \"signingIdentity\": null,\n      \"entitlements\": null,\n      \"exceptionDomain\": null\n    }\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRENURGRUVFREY4M0RGRDkKUldUWjM0UGY3djVkVGQ0aDluR0J6SnBUV2dET0FibXdzVmdTL2hIM21QQ1NCS3R2enllUStSd2oK\",\n      \"endpoints\": [\n        \"http://api.upgrade.toolsetlink.com/v1/tauri/upgrade?tauriKey=tyEi-iLVFxnRhGc9c_xApw&versionName={{current_version}}&appointVersionName=&devModelKey=&devKey=&target={{target}}&arch={{arch}}\",\n        \"https://github.com/codexu/note-gen/releases/latest/download/latest.json\"\n      ],\n      \"createUpdaterArtifacts\": true,\n      \"dangerousInsecureTransportProtocol\": true\n    }\n  }\n}\n"
  },
  {
    "path": "src-tauri/tauri.ios.conf.json",
    "content": "{\"bundle\": {\"iOS\": {\"developmentTeam\": \"RGZC4ZTJMU\"}}}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\", \"src/app/core/record/mark/control-scan.tsx.bak\"],\n  \"exclude\": [\"node_modules\", \"docs/**\"]\n}\n"
  }
]